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
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:
@@ -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
|
||||
@@ -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\")"'
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user