chore: import upstream snapshot with attribution
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:03:20 +08:00
commit 0ef5fcb1c5
1951 changed files with 606278 additions and 0 deletions
+555
View File
@@ -0,0 +1,555 @@
name: CI
# Intelligent + parallel pipeline (cutover from the old 4-version matrix):
# changes — paths-filter; skips heavy work for docs-only changes
# build-wheel — compile the Rust ext ONCE (fast `ci` cargo profile), share via artifact
# lint — ruff + mypy, once
# prefetch-model — download the embedding model ONCE (authenticated), warm shared cache
# test — 4 parallel shards (pytest-split), each a fresh runner VM; run offline
# test-extras / test-agno / build / commitlint / workflow-validation / *-e2e — preserved
#
# Notes: CPU-only torch everywhere (no CUDA stack); test shards run HF_HUB_OFFLINE.
# Multi-version (3.10/3.11/3.13) coverage on main is a planned follow-up.
# Windows wheel (win_amd64) built separately — builds the Rust ext just like the
# Linux wheel, then uploads as a separate artifact for downstream consumption.
on:
push:
branches: [main]
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- 'wiki/**'
- '**/*.md'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
# Cancel superseded runs on PRs/branches, but never cancel a main build.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PY_VERSION: "3.12"
# CPU-only torch — runners have no GPU; the default CUDA wheels pull ~2.5 GB.
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
jobs:
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
code: ${{ steps.filter.outputs.code }}
e2e: ${{ steps.filter.outputs.e2e }}
workflows: ${{ steps.filter.outputs.workflows }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- 'headroom/**'
- 'crates/**'
- '**/*.rs'
- 'pyproject.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'tests/**'
- 'scripts/**'
- '.github/workflows/**'
e2e:
- 'headroom/**'
- 'crates/**'
- 'docker/**'
- 'Dockerfile'
- 'e2e/**'
- 'scripts/install*'
- 'pyproject.toml'
workflows:
- '.github/workflows/**'
lint:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-lint-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-lint-
- run: python -m pip install --upgrade pip "ruff==0.15.17" "mypy==1.20.2"
- name: ruff check
run: ruff check .
- name: ruff format --check
run: ruff format --check .
- name: mypy
run: mypy headroom --ignore-missing-imports
build-wheel:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- uses: dtolnay/rust-toolchain@1.96.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Build wheel once (fast CI cargo profile)
run: |
python -m pip install --upgrade pip maturin
maturin build --profile ci --out dist --interpreter "python${PY_VERSION}"
- uses: actions/upload-artifact@v7
with:
name: headroom-wheel
path: dist/*.whl
retention-days: 1
build-wheel-windows:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Build wheel (fast CI cargo profile)
shell: bash
run: |
python -m pip install --upgrade pip maturin
maturin build --profile ci --out dist --interpreter "python${{ env.PY_VERSION }}"
- uses: actions/upload-artifact@v7
with:
name: headroom-wheel-windows
path: dist/*.whl
retention-days: 1
prefetch-model:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Cache HuggingFace model
id: hfcache
uses: actions/cache@v6
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-models-allMiniLM-v2
- name: Fetch all-MiniLM-L6-v2 once (authenticated, resilient)
if: steps.hfcache.outputs.cache-hit != 'true'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_HUB_DISABLE_TELEMETRY: "1"
run: |
python -m pip install --upgrade pip huggingface_hub
for i in 1 2 3 4 5 6; do
if python -c "from huggingface_hub import snapshot_download; snapshot_download('sentence-transformers/all-MiniLM-L6-v2')"; then exit 0; fi
echo "::warning::model fetch attempt $i failed; backing off"; sleep $((i * 30))
done
echo "::error::could not fetch all-MiniLM-L6-v2 from HuggingFace"; exit 1
test:
needs: [changes, build-wheel, prefetch-model]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
TRANSFORMERS_OFFLINE: "1"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ env.PY_VERSION }}-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-${{ env.PY_VERSION }}-
- name: Restore HuggingFace model cache (warmed by prefetch-model)
id: restore-hfcache
uses: actions/cache@v6
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-models-allMiniLM-v2
- name: Fallback model download if cache missed
if: steps.restore-hfcache.outputs.cache-hit != 'true'
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_HUB_DISABLE_TELEMETRY: "1"
TRANSFORMERS_OFFLINE: "0"
HF_HUB_OFFLINE: "0"
run: |
python -m pip install --upgrade pip huggingface_hub
for i in 1 2 3 4 5 6; do
if python -c "from huggingface_hub import snapshot_download; snapshot_download('sentence-transformers/all-MiniLM-L6-v2')"; then exit 0; fi
if [ "$i" -lt 6 ]; then echo "::warning::fallback model fetch attempt $i failed; backing off"; sleep $((i * 30)); fi
done
echo "::error::could not fetch all-MiniLM-L6-v2 from HuggingFace (fallback)"; exit 1
- name: Download prebuilt wheel
uses: actions/download-artifact@v8
with:
name: headroom-wheel
path: dist
- name: Install (CPU torch + prebuilt wheel + dev deps, no cargo rebuild)
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[dev]" pytest-split
# cwd's ./headroom source tree shadows the installed wheel; copy the
# compiled extension in so tests import it (no second cargo build).
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
cp "${SITE}/headroom/"_core*.so headroom/
python -c "from headroom._core import DiffCompressor; print('headroom._core OK')"
- name: Verify offline HuggingFace model cache
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
HF_HUB_DISABLE_TELEMETRY: "1"
run: python scripts/ci/verify_hf_model_cache.py
# Coverage upload: without this, codecov only receives reports from
# the two native-e2e workflows (3 CLI test files total), so head
# coverage reads ~6% and codecov/patch fails for ANY diff not
# exercised by those files — a false negative on every PR. The main
# suite runs here; its coverage must be what codecov sees.
- name: Run test shard ${{ matrix.shard }}/4
run: |
pytest tests scripts/tests \
--splits 4 --group ${{ matrix.shard }} \
--cov=headroom --cov-branch \
--cov-report=xml:coverage-${{ matrix.shard }}.xml \
--cov-report= \
--tb=short -q
- name: Upload coverage shard ${{ matrix.shard }} to Codecov
uses: codecov/codecov-action@v5
with:
files: coverage-${{ matrix.shard }}.xml
flags: python
name: python-shard-${{ matrix.shard }}
# Token is sent so uploads authenticate once the repo is activated on
# Codecov. Until then Codecov may 404 ("Repository not found"); either
# way, coverage upload is reporting-only and must never fail a build
# whose tests pass — so this stays non-blocking.
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
test-extras:
needs: [changes, build-wheel]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FASTEMBED_CACHE_PATH: ${{ github.workspace }}/.fastembed-cache
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-extras-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-extras-
- name: Cache fastembed model
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.fastembed-cache
key: ${{ runner.os }}-fastembed-bge-small-v1
- name: Download prebuilt wheel
uses: actions/download-artifact@v8
with:
name: headroom-wheel
path: dist
- name: Install (CPU torch + wheel[dev,relevance])
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[dev,relevance]"
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
cp "${SITE}/headroom/"_core*.so headroom/
python -c "from headroom._core import SmartCrusher; print('headroom._core OK')"
- name: Pre-fetch fastembed model (authenticated, resilient)
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_HUB_DISABLE_TELEMETRY: "1"
run: |
for i in 1 2 3 4 5; do
if python -c "from fastembed import TextEmbedding; TextEmbedding('BAAI/bge-small-en-v1.5')"; then exit 0; fi
echo "::warning::fastembed fetch attempt $i failed; backing off"; sleep $((i * 20))
done
echo "::error::could not fetch fastembed model from HuggingFace"; exit 1
- name: Run relevance tests
# Offline so fastembed reads the cache the prefetch step just warmed,
# without an unauthenticated cache-validation HEAD that could 429.
env:
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
run: pytest tests/test_relevance.py -v
test-agno:
needs: [changes, build-wheel]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Download prebuilt wheel
uses: actions/download-artifact@v8
with:
name: headroom-wheel
path: dist
- name: Install (CPU torch + wheel[dev,agno])
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[dev,agno]"
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
cp "${SITE}/headroom/"_core*.so headroom/
- name: Run agno tests
run: pytest tests/test_integrations/agno/ -v
test-dashboard-ui:
needs: [changes, build-wheel]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Download prebuilt wheel
uses: actions/download-artifact@v8
with:
name: headroom-wheel
path: dist
- name: Install (CPU torch + wheel[dev] + playwright)
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
WHEEL="$(ls dist/*.whl)"
pip install "${WHEEL}[dev]" playwright
SITE="$(python -c 'import sysconfig; print(sysconfig.get_path("platlib"))')"
cp "${SITE}/headroom/"_core*.so headroom/
- name: Install chromium
run: playwright install --with-deps chromium
- name: Run dashboard playwright tests
# Stub-based dashboard tests only (routes fully mocked, no network).
# tests/test_dashboard/test_live_feed.py needs a live proxy on
# localhost:8787 and stays excluded; the main shards keep skipping
# these via importorskip since playwright is not installed there.
env:
HEADROOM_PLAYWRIGHT_ARTIFACT_DIR: ${{ runner.temp }}/playwright-artifacts
run: pytest tests/test_dashboard_*_playwright.py -v
- name: Upload dashboard screenshots
if: always()
uses: actions/upload-artifact@v7
with:
name: dashboard-playwright-artifacts
path: ${{ runner.temp }}/playwright-artifacts
if-no-files-found: ignore
retention-days: 7
commitlint:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6
with:
configFile: .commitlintrc.json
build:
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-build-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-build-
- uses: dtolnay/rust-toolchain@1.96.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
# Smoke check that the SHIPPED build (release profile) + sdist are wired
# right; release.yml's matrix is what actually publishes to PyPI.
- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install 'maturin>=1.5,<2.0' twine
- name: Build wheel + sdist
run: |
maturin sdist --out dist
maturin build --release --out dist
- name: Check package
run: twine check dist/*
workflow-validation:
needs: changes
if: needs.changes.outputs.workflows == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Cache actionlint + act
id: tools-cache
uses: actions/cache@v6
with:
path: |
/usr/local/bin/actionlint
/usr/local/bin/act
# Key off the workflow file itself: when someone updates the
# download URLs to a newer tool version, the hash changes and
# the cache busts automatically.
key: ${{ runner.os }}-ci-tools-${{ hashFiles('.github/workflows/ci.yml') }}
- name: Install actionlint
if: steps.tools-cache.outputs.cache-hit != 'true'
run: |
curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash
sudo mv ./actionlint /usr/local/bin/actionlint
- name: Install act
if: steps.tools-cache.outputs.cache-hit != 'true'
run: |
curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash
sudo install ./bin/act /usr/local/bin/act
- name: Validate workflow files
run: bash scripts/validate-workflows.sh
docker-native-e2e:
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Build local Headroom image
run: docker build -t headroom-native-e2e:latest .
- name: Run Docker-native installer e2e
env:
HEADROOM_DOCKER_IMAGE: headroom-native-e2e:latest
run: bash e2e/docker-native-install.sh
- name: Run Docker-native compose smoke test
env:
HEADROOM_IMAGE: headroom-native-e2e:latest
HEADROOM_HOST_HOME: ${{ github.workspace }}
HEADROOM_WORKSPACE: ${{ github.workspace }}
run: |
mkdir -p .headroom .claude .codex .gemini
trap 'docker compose -f docker/docker-compose.native.yml down -v' EXIT
docker compose -f docker/docker-compose.native.yml up -d proxy
for attempt in $(seq 1 30); do
if curl --fail --silent http://127.0.0.1:8787/readyz >/dev/null; then
break
fi
if [ "$attempt" -eq 30 ]; then
docker compose -f docker/docker-compose.native.yml logs proxy
exit 1
fi
sleep 1
done
- name: Run Docker-native wrap e2e
run: |
docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
docker run --rm headroom-wrap-e2e
- name: Run Docker-native init e2e
run: |
docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
docker run --rm headroom-init-e2e
windows-native-wrapper:
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install test dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run native installer wrapper tests
run: pytest tests/test_install/test_native_installers.py -q
macos-native-wrapper:
needs: changes
if: needs.changes.outputs.e2e == 'true'
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install bash and test dependencies
run: |
brew install bash
python -m pip install --upgrade pip
python -m pip install --retries 10 --timeout 60 pytest
- name: Run native installer wrapper tests
run: |
BASH_PREFIX="$(brew --prefix bash)"
export PATH="$BASH_PREFIX/bin:$PATH"
pytest tests/test_install/test_native_installers.py -q
+119
View File
@@ -0,0 +1,119 @@
name: Dev Containers
on:
push:
branches: [main]
paths:
- ".devcontainer/**"
- ".github/workflows/devcontainers.yml"
- "pyproject.toml"
- "uv.lock"
pull_request:
branches: [main]
paths:
- ".devcontainer/**"
- ".github/workflows/devcontainers.yml"
- "pyproject.toml"
- "uv.lock"
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: default
config: .devcontainer/devcontainer.json
- name: memory-stack
config: .devcontainer/memory-stack/devcontainer.json
steps:
- uses: actions/checkout@v7
# Both variants exhaust the GitHub runner's disk, so free space up front
# on every variant. memory-stack brings up Neo4j + Postgres + Redis +
# Qdrant (PR #495: the diagnostic log writer hit "No space left on device"
# mid-smoke-test). The default variant's post-create `uv sync` fills the
# disk installing the ML wheel set — it failed copying numpy into the venv
# with "No space left on device" (os error 28). Previously this ran only
# on memory-stack, which left the default variant with no cushion.
#
# Reclaim ~14 GB by stripping preinstalled tools none of the devcontainer
# paths use (Android SDK, .NET, Haskell).
- name: Free runner disk
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: false
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "20"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Install Dev Container CLI
run: npm install -g @devcontainers/cli@0.85.0
- name: Start ${{ matrix.name }}
run: devcontainer up --workspace-folder . --config ${{ matrix.config }} --remove-existing-container
- name: Smoke test ${{ matrix.name }}
shell: bash
run: |
if [[ "${{ matrix.name }}" == "memory-stack" ]]; then
devcontainer exec --workspace-folder . --config ${{ matrix.config }} bash -lc 'git rev-parse --show-toplevel >/dev/null && uv --version && node --version && gh --version >/dev/null && uv run python -c "import socket; socket.create_connection((\"qdrant\", 6333), 5).close(); socket.create_connection((\"neo4j\", 7687), 5).close(); from mem0 import Memory; from qdrant_client import QdrantClient; import neo4j; import headroom; print(\"memory-stack smoke test passed\")"'
else
devcontainer exec --workspace-folder . --config ${{ matrix.config }} bash -lc 'git rev-parse --show-toplevel >/dev/null && uv --version && node --version && gh --version >/dev/null && uv run python -c "import headroom; print(\"default smoke test passed\")"'
fi
validate-worktree:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# The worktree devcontainer runs the same `uv sync --extra dev` build as
# the default validate job, which now pulls transformers 5.x. Copying that
# into the venv volume exhausts the GitHub runner's disk ("No space left on
# device", see PR #495). Reclaim ~14 GB by stripping preinstalled tools the
# build never touches — mirrors the memory-stack job's existing remedy.
- name: Free runner disk
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: true
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: false
- name: Create linked worktree
run: git worktree add "$RUNNER_TEMP/headroom-worktree" HEAD
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "20"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Install Dev Container CLI
run: npm install -g @devcontainers/cli@0.85.0
- name: Start linked worktree devcontainer
run: devcontainer up --workspace-folder "$RUNNER_TEMP/headroom-worktree" --config "$RUNNER_TEMP/headroom-worktree/.devcontainer/devcontainer.json" --remove-existing-container
- name: Smoke test linked worktree
run: devcontainer exec --workspace-folder "$RUNNER_TEMP/headroom-worktree" --config "$RUNNER_TEMP/headroom-worktree/.devcontainer/devcontainer.json" bash -lc 'git rev-parse --show-toplevel && uv run python -c "import headroom; print(\"worktree smoke test passed\")"'
+428
View File
@@ -0,0 +1,428 @@
name: Docker
on:
push:
branches: [main]
workflow_call:
inputs:
version:
description: "Version to stamp into the image contents and exact image tag"
required: false
type: string
enable_ref_tags:
description: "Whether to emit branch/PR ref tags"
required: false
default: true
type: boolean
workflow_dispatch:
inputs:
version:
description: "Version to stamp into the image contents and exact image tag"
required: false
release:
types: [published]
env:
REGISTRY: ghcr.io
permissions:
contents: read
packages: write
id-token: write # For cosign keyless signing via Sigstore OIDC
jobs:
# ─── Per-arch fan-out ──────────────────────────────────────────────────────
# Build each variant on its native architecture in parallel:
# linux/amd64 → ubuntu-24.04 (native x86_64)
# linux/arm64 → ubuntu-24.04-arm (native aarch64, GA Jan 2025)
#
# Pre-#377 we ran a single matrix job per variant on `ubuntu-latest` and
# let bake's `platforms = ["linux/amd64", "linux/arm64"]` do multi-arch
# via QEMU emulation — ~1h per variant. Native arm64 runners drop QEMU
# entirely and cut each variant to ~10 min on each arch in parallel.
#
# Each per-arch build pushes by digest only (no tags). The
# `docker-manifest` job below combines the per-arch digests into the
# final multi-arch tagged manifest, which is what users pull by tag.
docker-build:
runs-on: ${{ matrix.arch.runs_on }}
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
variant:
- { name: "", bake_target: runtime }
- { name: nonroot, bake_target: runtime-nonroot }
- { name: code, bake_target: runtime-code }
- { name: code-nonroot, bake_target: runtime-code-nonroot }
- { name: slim, bake_target: runtime-slim }
- { name: slim-nonroot, bake_target: runtime-slim-nonroot }
- { name: code-slim, bake_target: runtime-code-slim }
- { name: code-slim-nonroot, bake_target: runtime-code-slim-nonroot }
arch:
- { name: amd64, runs_on: ubuntu-24.04, platform: linux/amd64 }
- { name: arm64, runs_on: ubuntu-24.04-arm, platform: linux/arm64 }
steps:
- uses: actions/checkout@v7
- name: Normalize image name
id: image-name
run: |
image_name="$(printf '%s' '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
printf 'image_name=%s\n' "$image_name" >> "$GITHUB_OUTPUT"
- name: Determine image version
id: version
env:
MANUAL_VERSION: ${{ inputs.version || github.event.inputs.version }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
version="${MANUAL_VERSION#v}"
if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then
version="${RELEASE_TAG#v}"
fi
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
- name: Set up Python
if: steps.version.outputs.version != ''
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Sync versioned files for image build
if: steps.version.outputs.version != ''
run: |
python scripts/version-sync.py --version ${{ steps.version.outputs.version }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Labels (not tags) for the per-arch image. Tags belong on the
# multi-arch index manifest and are applied in docker-manifest.
- name: Extract image labels
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
- name: Build and push by digest (single platform)
id: bake
uses: docker/bake-action@v7
with:
files: |
./docker-bake.hcl
cwd://${{ steps.meta.outputs.bake-file-labels }}
targets: ${{ matrix.variant.bake_target }}
push: true
# `*.platform` overrides the [amd64,arm64] default in
# docker-bake.hcl. `push-by-digest=true,name-canonical=true`
# tells buildx to push the per-platform manifest with no tags
# — only the digest is recorded — so multiple per-arch builds
# can coexist in the registry until the manifest job stitches
# them. `name=<registry>/<image>` is REQUIRED here: with no
# `bake-file-tags` in scope (tags belong on the manifest, not
# per-arch), bake has no way to know the push target without
# the explicit `name=`. Removing it surfaces as the
# misleading "ERROR: tag is needed when pushing to registry"
# — see PR #378 (regression from #376). GHA cache is scoped
# per (variant, arch) so the two arches don't fight over the
# same cache key.
set: |
*.platform=${{ matrix.arch.platform }}
*.output=type=image,name=${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
*.cache-from=type=gha,scope=${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
*.cache-to=type=gha,mode=max,scope=${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
- name: Export digest
id: digest
env:
BAKE_METADATA: ${{ steps.bake.outputs.metadata }}
run: |
# Bake's metadata is one entry per target; for a single-target
# single-platform build it has exactly one digest. Pipe the
# JSON through a file (same ARG_MAX rationale as before) and
# extract that digest.
cat > "${RUNNER_TEMP}/bake_meta.json" <<'__HEADROOM_BAKE_META_EOF__'
${{ steps.bake.outputs.metadata }}
__HEADROOM_BAKE_META_EOF__
digest="$(jq -r 'to_entries[0].value."containerimage.digest" // empty' \
"${RUNNER_TEMP}/bake_meta.json")"
if [ -z "$digest" ]; then
echo "ERROR: no digest in bake metadata" >&2
cat "${RUNNER_TEMP}/bake_meta.json" >&2
exit 1
fi
printf 'digest=%s\n' "$digest" >> "$GITHUB_OUTPUT"
# Stage a marker file named after the bare hex digest. The
# manifest job downloads all per-arch markers for a variant
# and reconstructs `IMAGE@sha256:<digest>` references from
# the filenames.
mkdir -p "${RUNNER_TEMP}/digests"
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
# Smoke-test the built image before recording its digest. If the
# Python ABI is wrong (e.g. builder Python 3.11 vs distroless
# Python 3.13) pydantic_core._pydantic_core fails to dlopen and
# the import raises ModuleNotFoundError. Catching it here prevents
# a broken digest from reaching the manifest merge job and being
# tagged and published. Both python-slim and distroless variants
# expose python3 in PATH and honour the image's PYTHONPATH env.
- name: Smoke-test image (pydantic_core + headroom._core)
env:
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
DIGEST: ${{ steps.digest.outputs.digest }}
PLATFORM: ${{ matrix.arch.platform }}
run: |
docker run --rm \
--platform "$PLATFORM" \
--entrypoint python3 \
"${IMAGE}@${DIGEST}" \
-c "
import pydantic_core
from headroom._core import DiffCompressor, SmartCrusher
print('smoke-test OK: pydantic_core', pydantic_core.__version__,
'| DiffCompressor', DiffCompressor.__name__,
'| SmartCrusher', SmartCrusher.__name__)
"
- name: Upload digest marker
uses: actions/upload-artifact@v7
with:
# Variant + arch in the artifact name so the manifest job can
# download with `pattern: digests-<variant>-*` to gather all
# arches for one variant. `root` substitutes the empty-string
# variant since GHA artifact names can't end in a hyphen.
name: digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# ─── Per-variant manifest merge ────────────────────────────────────────────
# One job per variant, after both arch builds for that variant complete.
# `docker buildx imagetools create` stitches the two per-arch digests
# into a single multi-arch index manifest, applies the metadata-action
# tags, and that manifest is what users pull by `:tag`.
docker-manifest:
needs: docker-build
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
variant:
- { name: "", bake_target: runtime }
- { name: nonroot, bake_target: runtime-nonroot }
- { name: code, bake_target: runtime-code }
- { name: code-nonroot, bake_target: runtime-code-nonroot }
- { name: slim, bake_target: runtime-slim }
- { name: slim-nonroot, bake_target: runtime-slim-nonroot }
- { name: code-slim, bake_target: runtime-code-slim }
- { name: code-slim-nonroot, bake_target: runtime-code-slim-nonroot }
steps:
# No `actions/checkout` here: the manifest job only calls
# `docker buildx imagetools` against the registry and runs
# cosign — neither needs the repo on disk. Skipping checkout
# saves a few seconds across 8 parallel manifest jobs.
- name: Normalize image name
id: image-name
run: |
image_name="$(printf '%s' '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
printf 'image_name=%s\n' "$image_name" >> "$GITHUB_OUTPUT"
- name: Determine image version
id: version
env:
MANUAL_VERSION: ${{ inputs.version || github.event.inputs.version }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
version="${MANUAL_VERSION#v}"
if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then
version="${RELEASE_TAG#v}"
fi
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
- name: Compute short SHA
id: short-sha
run: printf 'sha=%s\n' "${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download per-arch digests for this variant
uses: actions/download-artifact@v8
with:
pattern: digests-${{ matrix.variant.name || 'root' }}-*
path: ${{ runner.temp }}/digests
merge-multiple: true
# Same tag rules as the pre-fan-out workflow — preserve every
# tag flavor (semver, ref, sha-prefixed, version-suffixed,
# bare variant) so existing pull URLs keep working.
- name: Extract metadata (variant)
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
tags: |
type=ref,event=branch,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name != 'release' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=ref,event=pr,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name != 'release' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=raw,value=dev,enable=${{ inputs.enable_ref_tags != 'false' && github.event_name == 'push' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=raw,value=${{ steps.version.outputs.version }},enable=${{ steps.version.outputs.version != '' }},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=raw,value=${{ steps.version.outputs.version }}-${{ steps.short-sha.outputs.sha }},enable=${{ steps.version.outputs.version != '' && matrix.variant.name == '' }}
type=raw,value=${{ steps.version.outputs.version }}-${{ matrix.variant.name }}-${{ steps.short-sha.outputs.sha }},enable=${{ steps.version.outputs.version != '' && matrix.variant.name != '' }}
type=semver,pattern={{version}},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=semver,pattern={{major}}.{{minor}},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=semver,pattern={{major}},suffix=${{ matrix.variant.name != '' && format('-{0}', matrix.variant.name) || '' }}
type=sha,format=short,prefix=${{ matrix.variant.name != '' && format('{0}-', matrix.variant.name) || 'sha-' }}
type=raw,value=${{ matrix.variant.name }},enable=${{ matrix.variant.name != '' }}
- name: Create multi-arch manifest
id: manifest
env:
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
DIGEST_DIR: ${{ runner.temp }}/digests
run: |
# Reconstruct full image references from the digest marker
# filenames (each file is named after the bare hex digest
# of one per-arch manifest).
if ! ls "${DIGEST_DIR}"/* >/dev/null 2>&1; then
echo "ERROR: no digests downloaded for variant '${{ matrix.variant.name || 'root' }}'" >&2
exit 1
fi
digest_refs=()
for f in "${DIGEST_DIR}"/*; do
digest="$(basename "$f")"
digest_refs+=("${IMAGE}@sha256:${digest}")
done
# Build `--tag` args from the metadata-action JSON output.
# Empty tags array is valid (PR builds without ref-tags
# enabled emit nothing); skip manifest creation in that case.
tag_args=()
while IFS= read -r tag; do
[ -n "$tag" ] && tag_args+=("--tag" "$tag")
done < <(jq -r '.tags[]?' <<< '${{ steps.meta.outputs.json }}')
if [ "${#tag_args[@]}" -eq 0 ]; then
echo "No tags to apply for variant '${{ matrix.variant.name || 'root' }}'; skipping manifest."
exit 0
fi
docker buildx imagetools create \
"${tag_args[@]}" \
"${digest_refs[@]}"
# Resolve the index manifest digest of the freshly pushed
# multi-arch manifest so cosign can sign it directly. We
# ask the registry via `imagetools inspect` and read the
# `.manifest.digest` field — that's the registry's own
# record of the index digest (no client-side hashing).
first_tag="$(jq -r '.tags[0]' <<< '${{ steps.meta.outputs.json }}')"
index_digest="$(docker buildx imagetools inspect "${first_tag}" \
--format '{{ json . }}' | jq -r '.manifest.digest')"
if [ -z "$index_digest" ] || [ "$index_digest" = "null" ]; then
echo "ERROR: could not resolve index digest for ${first_tag}" >&2
exit 1
fi
printf 'index_digest=%s\n' "$index_digest" >> "$GITHUB_OUTPUT"
printf 'first_tag=%s\n' "$first_tag" >> "$GITHUB_OUTPUT"
- name: Install cosign
if: steps.manifest.outputs.index_digest != ''
uses: sigstore/cosign-installer@v3
- name: Sign multi-arch index manifest with cosign
if: steps.manifest.outputs.index_digest != ''
env:
# Same routing as before: keep signature artifacts in a
# sibling GHCR package so the main image's version listing
# stays clean. Verifiers must export the same
# COSIGN_REPOSITORY when running 'cosign verify'. See the
# pre-#377 workflow for the GHCR/OCI-1.1 referrers context.
COSIGN_REPOSITORY: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}-signatures
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
INDEX_DIGEST: ${{ steps.manifest.outputs.index_digest }}
run: |
target="${IMAGE}@${INDEX_DIGEST}"
echo "Signing ${target} (signatures -> ${COSIGN_REPOSITORY})"
for attempt in 1 2 3; do
if cosign sign --yes "${target}"; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "ERROR: cosign signing failed after ${attempt} attempts" >&2
exit 1
fi
sleep_for=$((attempt * 10))
echo "cosign signing failed on attempt ${attempt}; retrying in ${sleep_for}s" >&2
sleep "$sleep_for"
done
promote-latest:
# Re-push the :latest tag pointing at the root variant *after* every
# variant manifest job has finished, so GHCR's package version
# listing (sorted by created_at) shows the root image with :latest
# at the top instead of whichever variant happened to finish last.
needs: docker-manifest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Normalize image name
id: image-name
run: |
image_name="$(printf '%s' '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
printf 'image_name=%s\n' "$image_name" >> "$GITHUB_OUTPUT"
- name: Determine image version
id: version
env:
MANUAL_VERSION: ${{ inputs.version || github.event.inputs.version }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
version="${MANUAL_VERSION#v}"
if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then
version="${RELEASE_TAG#v}"
fi
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Re-tag root image as :latest
if: steps.version.outputs.version != ''
env:
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
VERSION: ${{ steps.version.outputs.version }}
run: |
# Add a unique annotation so the resulting image index manifest gets
# a new digest, which makes GHCR record a fresh package version with
# current timestamp (otherwise the existing root manifest is reused
# and stays where it was in the version listing).
promoted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker buildx imagetools create \
--annotation "index:io.headroom.promoted-at=${promoted_at}" \
--tag "${IMAGE}:latest" \
"${IMAGE}:${VERSION}"
+75
View File
@@ -0,0 +1,75 @@
name: Deploy Documentation
on:
pull_request:
branches: [main]
paths:
- 'docs/**'
- 'wiki/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
push:
branches:
- main
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-docs-${{ hashFiles('mkdocs.yml') }}
restore-keys: ${{ runner.os }}-pip-docs-
- name: Install dependencies
run: pip install mkdocs-material
- name: Build docs
run: mkdocs build
deploy:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-docs-${{ hashFiles('mkdocs.yml') }}
restore-keys: ${{ runner.os }}-pip-docs-
- name: Install dependencies
run: pip install mkdocs-material
- name: Build and deploy
run: mkdocs gh-deploy --force
+152
View File
@@ -0,0 +1,152 @@
name: Evaluation Suite
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday 6am UTC
workflow_dispatch: # Manual trigger
pull_request:
paths:
- 'headroom/transforms/**'
- 'headroom/evals/**'
- 'headroom/compress.py'
jobs:
# Fast smoke test on PRs touching compression code (~$0.05, ~2 min)
smoke-test:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-eval-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-eval-
# `pip install -e .` invokes maturin (declared in pyproject.toml's
# build-system) which calls cargo to compile the Rust extension.
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.96.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Install dependencies (builds Rust extension via maturin)
run: |
pip install -e ".[all]"
python -c "from headroom._core import SmartCrusher; print('headroom._core OK:', SmartCrusher)"
- name: Run CCR round-trip (zero cost)
run: |
python -c "
from headroom.evals.runners.compression_only import CompressionOnlyRunner
runner = CompressionOnlyRunner()
cases = runner.generate_ccr_test_cases(n=50)
result = runner.evaluate_ccr_lossless(cases)
print(f'CCR Round-trip: {result.passed_cases}/{result.total_cases} passed')
assert result.passed, f'CCR failures: {result.errors}'
"
- name: Run tool schema compaction integrity eval (zero cost)
run: |
python -c "
from headroom.evals.runners.compression_only import CompressionOnlyRunner
runner = CompressionOnlyRunner()
result = runner.evaluate_tool_schema_compaction()
print(f'Tool schema compaction: {result.passed_cases}/{result.total_cases} passed, {result.total_tokens_saved} annotation tokens stripped')
assert result.passed, f'Schema compaction failures: {result.errors}'
"
# OPENAI_API_KEY is intentionally not set in the public OSS repo
# (the secret list is empty). The CCR round-trip step above is the
# mandatory gate; this step only runs when an operator has wired
# OPENAI_API_KEY as a repo secret (e.g. on a downstream fork). When
# missing, emit a loud GitHub `::warning::` annotation so the skip
# is visible in the run summary — never a silent pass.
- name: Run built-in tool output eval (skipped when OPENAI_API_KEY unset)
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
if [ -z "${OPENAI_API_KEY}" ]; then
echo "::warning title=Smoke eval skipped::OPENAI_API_KEY is not configured for this repo; only the CCR round-trip gate ran. Wire the secret to enable the live OpenAI eval."
exit 0
fi
python -m headroom.evals quick -n 8 --provider openai --model gpt-4o-mini
# Full Tier 1 suite, weekly or manual (~$3-5, ~30-45 min)
weekly-suite:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Cache pip
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-eval-${{ hashFiles('pyproject.toml') }}
restore-keys: ${{ runner.os }}-pip-eval-
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.96.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Install dependencies (builds Rust extension via maturin)
run: |
pip install -e ".[all]"
python -c "from headroom._core import SmartCrusher; print('headroom._core OK')"
- name: Run Tier 1 evaluation suite
run: |
if [ -z "${OPENAI_API_KEY}" ]; then
echo "::warning title=Weekly eval skipped::OPENAI_API_KEY is not configured for this repo; skipping the live Tier 1 suite."
mkdir -p eval_results
printf '%s\n\n%s\n' \
'# Weekly Evaluation Skipped' \
'OPENAI_API_KEY is not configured for this repository, so the live Tier 1 evaluation suite was skipped.' \
> eval_results/skipped.md
exit 0
fi
python -m headroom.evals suite --tier 1 --ci -o eval_results/
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# Recall-based fidelity report on the production routing path. Zero cost
# (synthetic structured cases -> Rust compressors; no model, no API, no
# secrets). Non-blocking: surfaces recall trends weekly without gating.
# The blocking per-PR fidelity gate lives in
# tests/test_compression_fidelity_regression.py (runs in the [dev] shard).
- name: Information-retention recall report (zero cost, non-blocking)
run: |
python -c "
from headroom.evals.runners.compression_only import CompressionOnlyRunner
runner = CompressionOnlyRunner()
cases = runner.generate_info_retention_cases(n=50)
result = runner.evaluate_information_retention(cases)
print(f'Information retention: {result.passed_cases}/{result.total_cases} cases >=0.9 recall, avg compression {result.avg_compression_ratio:.1%}')
if not result.passed:
print(f'::warning title=Fidelity recall::{result.failed_cases} case(s) fell below 0.9 recall: {result.errors[:3]}')
"
- name: Upload results
if: always()
uses: actions/upload-artifact@v7
with:
name: eval-results-${{ github.run_number }}
path: eval_results/
+42
View File
@@ -0,0 +1,42 @@
name: Init E2E
on:
pull_request:
branches: [main]
paths:
- 'headroom/**'
- 'crates/**'
- 'docker/**'
- 'Dockerfile'
- 'e2e/**'
- 'scripts/install*'
- 'pyproject.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'uv.lock'
- '.claude-plugin'
- '.github/plugin/**'
- 'plugins/headroom-agent-hooks/**'
- '.github/workflows/init-e2e.yml'
push:
branches: [main]
workflow_dispatch:
concurrency:
group: init-e2e-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
docker-init-e2e:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- name: Build init e2e image
run: docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
- name: Run init e2e container
run: docker run --rm headroom-init-e2e
+140
View File
@@ -0,0 +1,140 @@
name: Init Native E2E
# Cross-platform (linux / macos / windows) smoke tests for the per-subcommand
# ``headroom init -g <target>`` flows. Each matrix cell drops a noop shim for
# the target agent onto PATH and asserts ``headroom init -g <target>``
# succeeds, writes the expected settings file, and (for claude/codex) places
# hooks in the right place.
#
# Deliberately scoped to pull_request + push-to-main + workflow_dispatch to
# avoid bloating CI minutes on every push to every feature branch. The Docker
# init-e2e.yml still runs on every PR and provides the deeper functional
# coverage; this workflow exists to catch platform-specific bugs (Windows
# path separators, macos keychain prompts, PowerShell-vs-bash hook matchers)
# that the single-platform Docker suite can miss.
#
# Extending to other commands (``headroom install``, ``headroom wrap``) is
# expected to be a near-copy of this file. The shared composite action at
# ``.github/actions/headroom-e2e-setup`` absorbs the Python + shim setup so
# each per-command workflow only supplies its matrix and assertion steps.
on:
pull_request:
branches: [main]
paths:
- "headroom/cli/init.py"
- "headroom/install/**"
- "e2e/_lib/**"
- "e2e/init/**"
- ".github/actions/headroom-e2e-setup/**"
- ".github/workflows/init-native-e2e.yml"
push:
branches: [main]
workflow_dispatch:
jobs:
init-native:
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# Windows is excluded today: upstream `esaxx-rs` (transitively from
# `tokenizers`) and `ort-sys` (onnxruntime via `fastembed`) link
# with conflicting MSVC C runtime libraries (/MT vs /MD), so the
# Rust extension cannot build for `win_amd64` until the upstream
# CRT conflict is resolved. Re-add `windows-latest` once the wheel
# builds cleanly there. Tracked in the project plan; not a blocker
# for headroom-ai installs on Linux + macOS.
os: [ubuntu-latest, macos-latest]
target: [claude, codex, copilot, openclaw]
exclude:
# openclaw delegates to ``headroom wrap openclaw`` which needs a
# running OpenClaw CLI; it can't be shimmed cheaply, so it's
# covered by the bundled Docker e2e instead.
- target: openclaw
steps:
- uses: actions/checkout@v7
- name: Setup (shim=${{ matrix.target }})
uses: ./.github/actions/headroom-e2e-setup
with:
install-mode: deps-only-proxy
python-version: "3.11"
shim-target: ${{ matrix.target }}
- name: Verify shim is on PATH (POSIX)
if: runner.os != 'Windows'
shell: bash
run: |
which "${{ matrix.target }}"
- name: Verify shim is on PATH (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
# On Windows the shim is ``<target>.cmd``; Get-Command resolves via
# PATHEXT (same as Python's ``shutil.which`` used by headroom init).
# Git Bash's ``which`` cannot find ``.cmd`` shims, so we use pwsh.
$cmd = Get-Command "${{ matrix.target }}" -ErrorAction Stop
Write-Output $cmd.Source
- name: Run headroom init -g ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
headroom init -g "${{ matrix.target }}"
- name: Assert settings file (POSIX)
if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
case "${{ matrix.target }}" in
claude)
test -f "$HOME/.claude/settings.json"
grep -q "ANTHROPIC_BASE_URL" "$HOME/.claude/settings.json"
;;
codex)
test -f "$HOME/.codex/config.toml"
test -f "$HOME/.codex/hooks.json"
grep -q "headroom" "$HOME/.codex/config.toml"
;;
copilot)
test -f "$HOME/.copilot/config.json"
grep -q "SessionStart" "$HOME/.copilot/config.json"
;;
esac
- name: Assert settings file (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$home_ = $env:USERPROFILE
switch ("${{ matrix.target }}") {
"claude" {
$p = Join-Path $home_ ".claude\settings.json"
if (-not (Test-Path $p)) { throw "Missing $p" }
if (-not ((Get-Content $p -Raw) -match "ANTHROPIC_BASE_URL")) {
throw "settings.json missing ANTHROPIC_BASE_URL"
}
}
"codex" {
$c = Join-Path $home_ ".codex\config.toml"
$h = Join-Path $home_ ".codex\hooks.json"
if (-not (Test-Path $c)) { throw "Missing $c" }
if (-not (Test-Path $h)) { throw "Missing $h" }
if (-not ((Get-Content $c -Raw) -match "headroom")) {
throw "config.toml missing headroom provider"
}
}
"copilot" {
$p = Join-Path $home_ ".copilot\config.json"
if (-not (Test-Path $p)) { throw "Missing $p" }
if (-not ((Get-Content $p -Raw) -match "SessionStart")) {
throw "copilot config missing SessionStart hooks"
}
}
}
+68
View File
@@ -0,0 +1,68 @@
name: Install Native E2E
# Cross-platform smoke tests for safe ``headroom install`` paths. The goal here
# is portable CLI coverage that runs on real runners without mutating OS service
# managers or requiring Docker. Deeper lifecycle behavior remains covered by the
# existing native installer wrapper tests and install unit tests.
on:
pull_request:
branches: [main]
paths:
- "headroom/cli/install.py"
- "headroom/install/**"
- "tests/test_cli/test_install_cli.py"
- "tests/test_install/test_paths.py"
- ".github/actions/headroom-e2e-setup/**"
- ".github/workflows/install-native-e2e.yml"
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
install-native:
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# Windows is excluded today: upstream `esaxx-rs` (transitively from
# `tokenizers`) and `ort-sys` (onnxruntime via `fastembed`) link
# with conflicting MSVC C runtime libraries (/MT vs /MD), so the
# Rust extension cannot build for `win_amd64` until the upstream
# CRT conflict is resolved. Re-add `windows-latest` once the wheel
# builds cleanly there. Match init-native-e2e.yml so this workflow
# doesn't fail during setup before the install smoke tests run.
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./.github/actions/headroom-e2e-setup
with:
install-mode: deps-only-proxy
python-version: "3.11"
- name: Install pytest
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install --retries 10 --timeout 60 pytest pytest-cov
- name: Run install native smoke tests
shell: bash
run: |
pytest tests/test_cli/test_install_cli.py tests/test_install/test_paths.py --cov=headroom --cov-report=xml:coverage-install-native.xml --cov-report=term-missing -q
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage-install-native.xml
flags: install-native
name: install-native-${{ matrix.os }}
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
+30
View File
@@ -0,0 +1,30 @@
name: Merge Conflicts
# Reject unresolved Git merge-conflict markers committed into tracked files.
#
# This lives in its own workflow ON PURPOSE: ci.yml sets
# `on.pull_request.paths-ignore: ['**/*.md', ...]`, so a Markdown/CHANGELOG-only
# PR skips that workflow entirely. The conflict markers this guard exists to
# catch landed in CHANGELOG.md, so the check must run with no `paths-ignore`.
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
merge-conflicts:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- name: Reject unresolved merge-conflict markers
run: |
if git grep -nI -E '^(<{7}|>{7}|\|{7})( |$)' -- .; then
echo "::error::Unresolved Git merge-conflict markers found in tracked files (see matches above)."
exit 1
fi
echo "No merge-conflict markers found."
+164
View File
@@ -0,0 +1,164 @@
name: Network Diff Capture
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: network-diff-capture-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
PY_VERSION: "3.12"
jobs:
offline:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Install offline test tools
run: |
python -m pip install --upgrade pip
python -m pip install \
'tiktoken>=0.5.0' \
'pydantic>=2.0.0' \
'litellm==1.82.3' \
'click>=8.1.0' \
'rich>=13.0.0' \
'opentelemetry-api>=1.24.0' \
'ast-grep-cli>=0.30.0' \
'fastapi>=0.100.0' \
'uvicorn>=0.23.0' \
'httpx[http2]>=0.24.0' \
'openai>=2.14.0' \
'mcp>=1.0.0' \
'magika>=0.6.0' \
'zstandard>=0.20.0' \
'websockets>=13.0' \
'onnxruntime>=1.16.0' \
'transformers>=4.30.0' \
'watchdog>=4.0.0' \
'sqlite-vec>=0.1.6' \
pytest ruff mypy
- name: Lint capture code
run: ruff check headroom/capture headroom/cli/capture.py tests/test_network_diff_capture.py
- name: Format check capture code
run: ruff format --check headroom/capture headroom/cli/capture.py tests/test_network_diff_capture.py
- name: Type-check capture code
run: mypy headroom/capture/network_diff.py headroom/cli/capture.py
- name: Run capture tests
run: python -m pytest tests/test_network_diff_capture.py
- name: Validate compose model
env:
ANTHROPIC_API_KEY: dummy
run: docker compose -f docker/differential-network-capture/docker-compose.yml --profile run config
- name: Build Claude Code runner image
env:
ANTHROPIC_API_KEY: dummy
run: docker compose -f docker/differential-network-capture/docker-compose.yml --profile run build claude-direct
- name: Smoke Claude Code runner image
run: docker run --rm -e CLAUDE_COMMAND="claude --version" headroom-network-diff-claude-direct:latest
live-anthropic:
runs-on: ubuntu-latest
timeout-minutes: 90
needs: offline
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_PROMPT: "Summarize this repository in one sentence. Keep the answer under 30 words."
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ env.PY_VERSION }}
- name: Install report dependencies
run: |
python -m pip install --upgrade pip
python -m pip install \
'tiktoken>=0.5.0' \
'pydantic>=2.0.0' \
'litellm==1.82.3' \
'click>=8.1.0' \
'rich>=13.0.0' \
'opentelemetry-api>=1.24.0' \
'ast-grep-cli>=0.30.0' \
'fastapi>=0.100.0' \
'uvicorn>=0.23.0' \
'httpx[http2]>=0.24.0' \
'openai>=2.14.0' \
'mcp>=1.0.0' \
'magika>=0.6.0' \
'zstandard>=0.20.0' \
'websockets>=13.0' \
'onnxruntime>=1.16.0' \
'transformers>=4.30.0' \
'watchdog>=4.0.0' \
'sqlite-vec>=0.1.6'
- name: Run live Claude Code differential capture
if: env.ANTHROPIC_API_KEY != ''
working-directory: docker/differential-network-capture
run: |
set -euo pipefail
mkdir -p captures
docker compose up -d --build mitm-direct mitm-headroom-upstream headroom-proxy mitm-headroom-client
trap 'docker compose --profile run down -v' EXIT
for i in $(seq 1 90); do
if docker compose exec -T headroom-proxy curl --fail --silent http://127.0.0.1:8787/readyz >/dev/null; then
break
fi
if [ "$i" -eq 90 ]; then
docker compose logs headroom-proxy
exit 1
fi
sleep 2
done
docker compose --profile run run --rm claude-direct
docker compose --profile run run --rm claude-headroom
- name: Report skipped live capture
if: env.ANTHROPIC_API_KEY == ''
run: |
echo "::warning title=Live network diff skipped::ANTHROPIC_API_KEY is not configured for this repository; offline harness checks ran, but live Claude Code capture was skipped."
mkdir -p docker/differential-network-capture/captures
cat > docker/differential-network-capture/captures/skipped.md <<'EOF'
# Live Network Diff Capture Skipped
`ANTHROPIC_API_KEY` is not configured for this repository.
EOF
- name: Generate network diff report
if: env.ANTHROPIC_API_KEY != ''
run: |
python -m headroom.cli capture network-diff \
--direct docker/differential-network-capture/captures/direct.jsonl \
--headroom docker/differential-network-capture/captures/headroom-client.jsonl \
--output docker/differential-network-capture/captures/report.md \
--json-output docker/differential-network-capture/captures/report.json
- name: Upload capture artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: network-diff-capture-${{ github.run_number }}
path: docker/differential-network-capture/captures/
if-no-files-found: warn
+41
View File
@@ -0,0 +1,41 @@
name: OpenCode Plugin
# The OpenCode plugin (plugins/opencode) is the routing shim that carries
# `headroom wrap opencode` traffic through the proxy, yet nothing in CI ever
# compiled it — so TypeScript / @types/node major bumps and source changes had
# zero build evidence. This gate runs the plugin's own typecheck + build + test
# whenever it (or this workflow) changes.
on:
pull_request:
branches: [main]
paths:
- "plugins/opencode/**"
- ".github/workflows/opencode-plugin.yml"
push:
branches: [main]
paths:
- "plugins/opencode/**"
- ".github/workflows/opencode-plugin.yml"
permissions:
contents: read
jobs:
build:
name: typecheck + build + test
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: plugins/opencode
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm
cache-dependency-path: plugins/opencode/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm run build
- run: npm test
+228
View File
@@ -0,0 +1,228 @@
name: PR Governance
on:
pull_request_target:
types: [opened, edited, reopened, synchronize, ready_for_review, converted_to_draft]
schedule:
# Keep labels fresh even when base branches move or checks finish later.
- cron: '23 14 * * 1-5'
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
checks: read
statuses: read
concurrency:
group: pr-health-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
template:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Fetch current PR body
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > .pr-body.md
- name: Validate PR template
id: validate
run: python3 scripts/pr-governance.py --event "$GITHUB_EVENT_PATH" --body-file .pr-body.md --report .pr-governance-report.json
- name: Append governance summary
run: |
python3 - <<'PY'
import json
import os
from pathlib import Path
report = json.loads(Path(".pr-governance-report.json").read_text(encoding="utf-8"))
summary = report["summary_markdown"].strip()
with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as handle:
handle.write(f"{summary}\n")
PY
- name: Ensure governance labels exist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
gh label create "status: needs author action" \
--repo "$REPO" \
--color "d93f0b" \
--description "Pull request body or readiness checklist still needs author updates" \
--force
gh label create "status: ready for review" \
--repo "$REPO" \
--color "0e8a16" \
--description "Pull request body is complete and the author marked it ready for human review" \
--force
- name: Sync governance comment and labels
if: steps.validate.outputs.is_bot_pr != 'true'
uses: actions/github-script@v7
env:
REPORT_PATH: .pr-governance-report.json
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync(process.env.REPORT_PATH, 'utf8'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.pull_request.number;
const marker = report.comment_marker;
const body = `${marker}\n${report.comment_markdown}`.trim();
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find(
(comment) =>
comment.user?.type === 'Bot' && typeof comment.body === 'string' && comment.body.includes(marker),
);
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
if (report.labels_to_add.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: report.labels_to_add,
});
}
for (const label of report.labels_to_remove) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
- name: Report incomplete PR body
if: steps.validate.outputs.valid != 'true'
run: |
echo "PR template validation found missing fields. The governance comment and labels identify the required author updates."
label:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Ensure maintenance labels exist
run: |
set -euo pipefail
gh label create "status: needs rebase" \
--repo "$REPO" \
--color "fbca04" \
--description "Pull request branch is behind the base branch" \
--force
gh label create "status: has conflicts" \
--repo "$REPO" \
--color "d73a4a" \
--description "Pull request has merge conflicts with the base branch" \
--force
gh label create "status: ci failing" \
--repo "$REPO" \
--color "d73a4a" \
--description "Required or reported CI checks are failing" \
--force
gh label create "status: needs author action" \
--repo "$REPO" \
--color "d93f0b" \
--description "Pull request body or readiness checklist still needs author updates" \
--force
gh label create "status: ready for review" \
--repo "$REPO" \
--color "0e8a16" \
--description "Pull request body is complete and the author marked it ready for human review" \
--force
- name: Label open pull requests
run: |
set -euo pipefail
if jq -e '.pull_request.number' "$GITHUB_EVENT_PATH" >/dev/null; then
pr_numbers="$(jq -r '.pull_request.number' "$GITHUB_EVENT_PATH")"
else
pr_numbers="$(gh pr list --repo "$REPO" --state open --limit 100 --json number --jq '.[].number')"
fi
for pr in $pr_numbers; do
data="$(gh pr view "$pr" --repo "$REPO" \
--json isDraft,labels,mergeStateStatus,reviewDecision,statusCheckRollup)"
merge_state="$(jq -r '.mergeStateStatus // "UNKNOWN"' <<<"$data")"
check_state="$(python3 .github/scripts/pr-health-labels.py --state-json "$data")"
is_draft="$(jq -r '.isDraft' <<<"$data")"
review_decision="$(jq -r '.reviewDecision // ""' <<<"$data")"
if [[ "$merge_state" == "BEHIND" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: needs rebase"
elif [[ "$merge_state" != "UNKNOWN" ]]; then
gh pr edit "$pr" --repo "$REPO" --remove-label "status: needs rebase" || true
fi
if [[ "$merge_state" == "DIRTY" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: has conflicts"
elif [[ "$merge_state" != "UNKNOWN" ]]; then
gh pr edit "$pr" --repo "$REPO" --remove-label "status: has conflicts" || true
fi
if [[ "$check_state" == "failing" ]]; then
gh pr edit "$pr" --repo "$REPO" --add-label "status: ci failing"
else
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ci failing" || true
fi
if [[ "$merge_state" == "BEHIND" || "$merge_state" == "DIRTY" || "$check_state" == "failing" || "$is_draft" == "true" || "$review_decision" == "CHANGES_REQUESTED" ]]; then
gh pr edit "$pr" --repo "$REPO" --remove-label "status: ready for review" || true
fi
done
+57
View File
@@ -0,0 +1,57 @@
name: Publish to PyPI (manual fallback)
# DEPRECATED: This workflow is superseded by release.yml's matrix-based
# build-and-publish flow. It exists as a manual fallback in case release.yml
# is broken and a hotfix needs to be pushed without going through the
# normal tag-driven pipeline. Single-platform; produces only the linux
# x86_64 wheel + sdist. For full cross-platform release, use release.yml.
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # For trusted publishing
contents: write # For uploading release assets
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.96.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- name: Install build tools
run: |
python -m pip install --upgrade pip 'maturin>=1.5,<2.0' cyclonedx-bom
- name: Build wheel + sdist
run: |
maturin sdist --out dist
maturin build --release --out dist
- name: Generate SBOM (CycloneDX)
run: |
pip install -e ".[proxy]"
cyclonedx-py environment \
--output-format json \
--outfile dist/headroom-sbom.cdx.json
- name: Upload SBOM to release
uses: softprops/action-gh-release@v3
with:
files: dist/headroom-sbom.cdx.json
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@v1.13.0
+57
View File
@@ -0,0 +1,57 @@
name: Release Please
# What this does
# ----------------
# release-please watches `main` for conventional-commit traffic and
# maintains a single "Release vX.Y.Z" PR that aggregates everything
# released since the last tag. Merging that PR is what triggers an
# actual PyPI / npm / GitHub-Release publish (via release.yml, which
# fires on the published-release event the bot emits at merge time).
#
# This replaces the prior "every push to main is a release" pattern
# that burned PyPI's per-project storage quota by uploading a fresh
# wheel matrix (~200 MB) for each merged `fix:` / `feat:` PR.
#
# Day-to-day:
# - Merge a `fix:` PR into main -> bot updates the release PR
# - Merge a `feat:` PR into main -> bot bumps minor in release PR
# - Merge `ci:` / `docs:` / `chore:` -> no PR change (hidden)
# - Ready to ship -> merge the release PR
# (bot tags + emits release event;
# release.yml does the actual builds + publishes)
#
# Config lives in `.release-please-config.json`; current versions
# tracked in `.release-please-manifest.json`.
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
concurrency:
# Serialize bot runs on main so two pushes don't race the
# release-PR update. We never cancel mid-flight — losing a manifest
# write would mean the next push computes the wrong base version.
group: release-please-${{ github.ref }}
cancel-in-progress: false
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v5
with:
# PAT (not GITHUB_TOKEN): a release/tag created by GITHUB_TOKEN does
# NOT emit events that trigger other workflows, so release.yml
# (PyPI/npm) and docker.yml — which fire on `release: published` —
# never ran, and releases had to be cut by hand. A PAT is treated as a
# real user, so the release it creates DOES trigger those publishes; it
# also lets the bot tag past branch/tag protection. Falls back to
# GITHUB_TOKEN when the secret is unset (the release PR still opens; it
# just won't trigger the downstream publishes).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: .release-please-config.json
manifest-file: .release-please-manifest.json
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
name: rust
on:
push:
branches: [ main, rust-rewrite ]
paths:
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'tests/parity/**'
- 'Makefile'
- '.github/workflows/rust.yml'
pull_request:
paths:
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'tests/parity/**'
- 'Makefile'
- '.github/workflows/rust.yml'
schedule:
# Nightly parity run at 07:17 UTC (weekdays only). Phase 0 allows failure.
- cron: '17 7 * * 1-5'
concurrency:
group: rust-${{ github.ref }}
cancel-in-progress: true
# Default permissions: read-only. Individual jobs override only what they need.
# Mitigates CodeQL/CWE-275 (missing-workflow-permissions): the GITHUB_TOKEN
# defaults to whatever the repo policy is, which can be read-write. Pinning
# this here means even if the repo default changes, this workflow stays safe.
permissions:
contents: read
jobs:
test:
name: test (ubuntu)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
# Pin action code to @stable (latest fixes), toolchain version
# via input. The @1.95.0 ref shipped action code that errors on
# ubuntu-latest with `detected conflict: 'bin/cargo-clippy'`
# because the pre-installed runner Rust collides with the
# clippy-preview component install.
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
components: rustfmt, clippy
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
- name: cargo fmt --check
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --workspace -- -D warnings
- name: cargo test
run: cargo test --workspace
simulator-e2e:
name: simulator e2e (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v7
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- name: Cache cargo registry + build
uses: Swatinem/rust-cache@v2
- name: cargo test simulator-backed proxy e2e
run: cargo test -p headroom-proxy --test e2e_simulators
wheels:
name: wheels (${{ matrix.target }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
maturin-target: x86_64
- os: macos-14
target: aarch64-apple-darwin
maturin-target: aarch64-apple-darwin
- os: macos-15-intel
target: x86_64-apple-darwin
maturin-target: x86_64-apple-darwin
# Intel macOS uses `ort-load-dynamic` (no prebuilt ORT from ort-sys);
# Apple Silicon bundles ORT via `ort-download-binaries-rustls-tls`.
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: "Build wheel (single-wheel architecture builds headroom-ai)"
uses: PyO3/maturin-action@v1
# Maturin reads `[tool.maturin]` from the root `pyproject.toml`
# which points at `crates/headroom-py/Cargo.toml` for the cdylib.
# Output is `headroom_ai-<ver>-<py>-<py>-<platform>.whl` containing
# both Python source and the compiled `headroom/_core.so`.
with:
command: build
args: --release --out dist
target: ${{ matrix.maturin-target }}
- name: Upload wheel artifact
uses: actions/upload-artifact@v7
with:
name: wheels-${{ matrix.target }}
path: dist/*.whl
audit:
name: audit
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- uses: Swatinem/rust-cache@v2
- name: Install cargo-audit + cargo-deny
uses: taiki-e/install-action@v2
with:
tool: cargo-audit,cargo-deny
- name: cargo audit (soft-fail)
continue-on-error: true
run: cargo audit
- name: cargo deny check licenses
continue-on-error: true
run: cargo deny check licenses
parity-nightly:
name: parity (nightly, allowed to fail during Phase 0)
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.95.0
- uses: actions/setup-python@v6
with:
python-version: '3.11'
- uses: Swatinem/rust-cache@v2
- name: Install deps
run: |
python -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install maturin
pip install -e .
- name: Run parity harness
run: |
source .venv/bin/activate
make test-parity
+120
View File
@@ -0,0 +1,120 @@
name: Security
# Security gate: dependency vulnerability scanning (SCA), static analysis
# (CodeQL/SAST), and secret scanning. Runs on every PR to main, on push to
# main, weekly (to catch newly-disclosed CVEs without a code change), and on
# demand. Each job is an independent required check.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Mondays 06:00 UTC — surface CVEs disclosed since the last commit.
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
# ---- SCA: dependency vulnerability scan -------------------------------
# Audits the PRODUCTION dependency set ([all]) exported from uv.lock. The
# `benchmark` extra is intentionally excluded from [all] (it pulls lm-eval's
# sqlitedict/nltk, which carry unpatchable upstream High CVEs and are never
# installed in production), so this gate fails only on actionable findings.
dependency-audit:
name: Dependency audit (pip-audit)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Export production dependency set from uv.lock
run: |
uv export --frozen --no-dev --no-emit-project --no-hashes \
--extra all --format requirements-txt > requirements-prod.txt
echo "Production dependencies audited:"
wc -l requirements-prod.txt
- name: Audit dependencies (pip-audit)
uses: pypa/gh-action-pip-audit@v1.1.0
with:
inputs: requirements-prod.txt
# ---- SAST: CodeQL static analysis ------------------------------------
codeql:
name: CodeQL (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
security-events: write
actions: read
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-extended
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# ---- Secret scanning -------------------------------------------------
# Uses the gitleaks BINARY (MIT-licensed, no key) instead of
# gitleaks-action, which requires a paid GITLEAKS_LICENSE for organization
# repos. On PRs we scan only the PR's commits so pre-existing history can't
# block a PR; on push/schedule we scan the working tree. Config + allowlist
# live in .gitleaks.toml at the repo root (auto-loaded).
secret-scan:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install gitleaks
run: |
version=8.18.4
curl -sSfL \
"https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_linux_x64.tar.gz" \
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
sudo install /tmp/gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Scan for secrets
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$BASE_SHA" ]; then
echo "Scanning PR commits ${BASE_SHA}..HEAD"
gitleaks detect --source . --log-opts="${BASE_SHA}..HEAD" --redact --no-banner
else
echo "Scanning working tree"
gitleaks detect --source . --no-git --redact --no-banner
fi
+60
View File
@@ -0,0 +1,60 @@
name: Stale Triage
on:
schedule:
# Daily weekday pass during US morning hours.
- cron: '17 15 * * 1-5'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
concurrency:
group: stale-triage
cancel-in-progress: false
jobs:
stale:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Ensure stale label exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh label create "status: stale" \
--repo "${{ github.repository }}" \
--color "ededed" \
--description "No recent activity; may be closed if it stays inactive" \
--force
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 200
remove-stale-when-updated: true
exempt-all-milestones: true
exempt-issue-labels: pinned,security,good first issue,help wanted,needs reproduction
exempt-pr-labels: pinned,security,dependencies,release,do not merge
stale-issue-label: "status: stale"
days-before-issue-stale: 60
days-before-issue-close: 14
stale-issue-message: >
This issue has had no recent activity and is being marked stale.
Please comment with new context if it is still relevant.
close-issue-message: >
Closing this issue due to continued inactivity. It can be reopened
if there is new information or a clear next step.
stale-pr-label: "status: stale"
days-before-pr-stale: 30
days-before-pr-close: 14
stale-pr-message: >
This pull request has had no recent activity and is being marked
stale. Please rebase, resolve conflicts, or comment if it is still
actively being worked.
close-pr-message: >
Closing this pull request due to continued inactivity. It can be
reopened when it is ready for review again.
+41
View File
@@ -0,0 +1,41 @@
name: Wrap E2E
on:
pull_request:
branches: [main]
paths:
- 'headroom/**'
- 'crates/**'
- 'docker/**'
- 'Dockerfile'
- 'e2e/**'
- 'scripts/install*'
- 'pyproject.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'uv.lock'
- 'sdk/typescript/**'
- 'plugins/openclaw/**'
- '.github/workflows/wrap-e2e.yml'
push:
branches: [main]
workflow_dispatch:
concurrency:
group: wrap-e2e-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
docker-wrap-e2e:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v7
- name: Build wrap e2e image
run: docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
- name: Run wrap e2e container
run: docker run --rm headroom-wrap-e2e
+73
View File
@@ -0,0 +1,73 @@
name: Wrap Native E2E
# Cross-platform smoke tests for the hidden ``headroom wrap ... --prepare-only``
# flows. These reuse the existing pytest bridge cases so we exercise the real
# CLI on linux / macos without depending on agent binaries or long-lived proxy
# processes. Windows will be added once the upstream CRT conflict is resolved
# (see matrix comment below).
#
# This complements the Docker-native wrap e2e by catching host-specific issues
# such as home-directory layout and filesystem quirks in prepare-only config
# injection.
on:
pull_request:
branches: [main]
paths:
- "headroom/cli/**"
- "headroom/providers/**"
- "headroom/rtk/**"
- "tests/test_cli/test_wrap_bridge.py"
- ".github/actions/headroom-e2e-setup/**"
- ".github/workflows/wrap-native-e2e.yml"
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
wrap-native:
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# Windows is excluded today: upstream `esaxx-rs` (transitively from
# `tokenizers`) and `ort-sys` (onnxruntime via `fastembed`) link
# with conflicting MSVC C runtime libraries (/MT vs /MD), so the
# Rust extension cannot build for `win_amd64` until the upstream
# CRT conflict is resolved. Re-add `windows-latest` once the wheel
# builds cleanly there. Match init-native-e2e.yml so this workflow
# doesn't fail during setup before the wrap smoke tests run.
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./.github/actions/headroom-e2e-setup
with:
install-mode: deps-only-proxy
python-version: "3.11"
- name: Install pytest
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install --retries 10 --timeout 60 pytest pytest-cov
- name: Run wrap native bridge tests
shell: bash
run: |
pytest tests/test_cli/test_wrap_bridge.py --cov=headroom --cov-report=xml:coverage-wrap-native.xml --cov-report=term-missing -q
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: ./coverage-wrap-native.xml
flags: wrap-native
name: wrap-native-${{ matrix.os }}
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false