Skip to content
This repository was archived by the owner on May 30, 2026. It is now read-only.

release(ci): keep extension isolation release inside gates #345

release(ci): keep extension isolation release inside gates

release(ci): keep extension isolation release inside gates #345

Workflow file for this run

# Ouroboros CI — Four-tier cross-platform testing and release pipeline
#
# Tier 1 (Quick): Push to ouroboros (code paths) → Ubuntu-only tests (~1 min)
# Tier 2 (Full): Push to ouroboros-stable / manual / tag → Full 3-OS matrix (~5 min)
# Tier 2.5 (Integration): Push to main / ouroboros / ouroboros-stable / manual / tag → Real-provider tests (~2 min)
# Tier 3 (Build+Release): Tag v* → PyInstaller + GitHub Release (~15 min)
#
# Tier 2.5 requires OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY /
# CLOUDRU_FOUNDATION_MODELS_API_KEY in
# repository secrets and runs the `integration` pytest marker; locally these
# tests are excluded by `addopts = -m 'not integration'` in pyproject.toml.
name: CI
# Two separate push triggers: branches have path filters, tags do not.
# This ensures tag pushes always fire (even if only VERSION/README changed).
on:
push:
branches: [main, ouroboros, ouroboros-stable]
paths:
- 'ouroboros/**'
- 'supervisor/**'
- 'server.py'
- 'tests/**'
- 'web/**'
- 'requirements.txt'
- 'pyproject.toml'
- '.github/workflows/**'
- 'build.sh'
- 'build_linux.sh'
- 'build_windows.ps1'
- 'Dockerfile'
- 'scripts/**'
- 'packaging/**'
- 'VERSION'
- 'README.md'
- 'launcher.py'
tags:
- 'v*'
workflow_dispatch:
# Note: GitHub Actions evaluates `branches` + `paths` together but `tags`
# separately — a tag push matching `v*` will trigger regardless of paths.
jobs:
# ──────────────────────────────────────────────────────────────────
# Tier 1: Quick tests on Ubuntu (every push to ouroboros)
# ──────────────────────────────────────────────────────────────────
quick-test:
if: |
github.event_name == 'push'
&& github.ref == 'refs/heads/ouroboros'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run tests
run: python -m pytest tests/ -q --tb=short
- name: Guard extracted transport imports stay out of core
run: python -m pytest tests/test_no_core_a2a_telegram_imports.py -q
# ──────────────────────────────────────────────────────────────────
# Tier 2: Full matrix (stable branch, manual, or tag push)
# ──────────────────────────────────────────────────────────────────
full-test:
if: |
github.ref == 'refs/heads/ouroboros-stable'
|| github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run tests
run: python -m pytest tests/ -q --tb=short
- name: Guard extracted transport imports stay out of core
run: python -m pytest tests/test_no_core_a2a_telegram_imports.py -q
# ──────────────────────────────────────────────────────────────────
# Tier 2.5: Integration tests against real provider APIs
# Triggered on push to main / ouroboros / ouroboros-stable, manual,
# or tag v*. Requires OPENROUTER_API_KEY / OPENAI_API_KEY /
# ANTHROPIC_API_KEY / CLOUDRU_FOUNDATION_MODELS_API_KEY in repository secrets. The `integration` pytest
# marker (in pyproject.toml) controls inclusion via `-m integration`;
# within an included test file, missing-key skipping is done by per-
# test `@pytest.mark.skipif(not os.environ.get(KEY))` decorators (see
# tests/test_provider_integration.py). NOT a `needs:` of build/
# release: a provider outage must not block a tagged release.
# ──────────────────────────────────────────────────────────────────
integration-test:
if: |
github.event_name == 'workflow_dispatch'
|| github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/ouroboros'
|| github.ref == 'refs/heads/ouroboros-stable'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run integration tests
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLOUDRU_FOUNDATION_MODELS_API_KEY: ${{ secrets.CLOUDRU_FOUNDATION_MODELS_API_KEY }}
CLOUDRU_FOUNDATION_MODELS_BASE_URL: ${{ secrets.CLOUDRU_FOUNDATION_MODELS_BASE_URL }}
run: python -m pytest tests/test_provider_integration.py -m integration -q --tb=short
marker-guards:
if: |
github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Guard non-empty browser marker lanes
run: |
set -euo pipefail
python -m pytest tests/ --collect-only -m browser -q | tee /tmp/browser-collect.txt
python -m pytest tests/ --collect-only -m ui_browser -q | tee /tmp/ui-collect.txt
python -m pytest tests/ --collect-only -m ui_browser_docker -q | tee /tmp/ui-docker-collect.txt
python -m pytest tests/ --collect-only -m portable_detail -q | tee /tmp/portable-collect.txt
! grep -q "no tests collected" /tmp/browser-collect.txt
! grep -q "no tests collected" /tmp/ui-collect.txt
! grep -q "no tests collected" /tmp/ui-docker-collect.txt
! grep -q "no tests collected" /tmp/portable-collect.txt
ui-smoke:
if: |
github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install UI smoke dependencies
run: |
pip install -r requirements.txt
pip install pytest playwright
python -m playwright install --with-deps chromium
- name: Run host UI smoke
env:
OUROBOROS_RUN_UI_SMOKE: "1"
run: python -m pytest tests/ -m ui_browser -q --tb=short
- name: Run browser tools Chromium smoke
run: python -m pytest tests/test_browser_tools_smoke.py -m browser -q --tb=short
docker-ui-smoke:
if: |
github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t ouroboros-web:test .
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install UI smoke dependencies
run: |
pip install -r requirements.txt
pip install pytest playwright
python -m playwright install --with-deps chromium
- name: Run Docker UI smoke
env:
OUROBOROS_RUN_DOCKER_UI_SMOKE: "1"
OUROBOROS_DOCKER_UI_IMAGE: ouroboros-web:test
run: python -m pytest tests/test_ui_smoke_playwright.py -m ui_browser_docker -q --tb=short
docker-portable-test:
if: |
github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t ouroboros-web:test .
- name: Run portable detail tests in Docker
run: |
docker run --rm --entrypoint sh -e OUROBOROS_EXPECT_HEADLESS_SHELL=1 ouroboros-web:test -c \
"PLAYWRIGHT_BROWSERS_PATH=0 python -m playwright install --only-shell chromium && python -m pytest tests/ -m portable_detail -q --tb=short"
# ──────────────────────────────────────────────────────────────────
# Tier 3: Build & Release (tag push only)
# ──────────────────────────────────────────────────────────────────
release-preflight:
if: startsWith(github.ref, 'refs/tags/v')
needs: full-test
runs-on: ubuntu-latest
outputs:
is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Validate tag matches VERSION
id: release_meta
run: |
python - <<'PY'
import os
import pathlib
import re
from ouroboros.tools.release_sync import is_release_version
version = pathlib.Path("VERSION").read_text(encoding="utf-8").strip()
tag = os.environ["GITHUB_REF_NAME"].strip()
expected_tag = f"v{version}"
if tag != expected_tag:
raise SystemExit(f"Release tag mismatch: {tag} != {expected_tag}")
if not is_release_version(version):
raise SystemExit(f"VERSION is not a supported release version: {version!r}")
is_prerelease = bool(re.search(r'(?:rc|alpha|beta|a|b)\.?\d+$', version, re.IGNORECASE))
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"is_prerelease={'true' if is_prerelease else 'false'}\n")
PY
build:
if: startsWith(github.ref, 'refs/tags/v')
needs: [full-test, release-preflight]
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
artifact: dmg
- os: ubuntu-latest
artifact: tar.gz
- os: windows-latest
artifact: zip
runs-on: ${{ matrix.os }}
env:
OUROBOROS_MANAGED_SOURCE_BRANCH: ouroboros
OUROBOROS_RELEASE_TAG: ${{ github.ref_name }}
# Apple signing secrets at JOB LEVEL with a per-matrix-shard guard.
#
# Step-level `if:` conditions can only read `env.*`, never `secrets.*`
# directly (GitHub Actions rejects the workflow with "Unrecognized
# named-value: 'secrets'"). See docs/DEVELOPMENT.md::"GitHub Actions:
# secrets in step-level if conditions".
#
# The `matrix.os == 'macos-latest' && ... || ''` GHA expression keeps
# the Apple signing/notarization values **scoped to the macOS shard
# only** — Linux and Windows shards (which run `build_linux.sh` and
# `build_windows.ps1` respectively, neither of which needs Apple
# creds) receive empty strings. This avoids exposing the signing
# material to non-macOS build subprocesses where it has no business
# being. When a secret is not configured even on macOS, the value
# is also empty string (not unset), and the gate `env.X != ''`
# evaluates false — the signing/notarization steps skip cleanly.
BUILD_CERTIFICATE_BASE64: ${{ matrix.os == 'macos-latest' && secrets.BUILD_CERTIFICATE_BASE64 || '' }}
P12_PASSWORD: ${{ matrix.os == 'macos-latest' && secrets.P12_PASSWORD || '' }}
KEYCHAIN_PASSWORD: ${{ matrix.os == 'macos-latest' && secrets.KEYCHAIN_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ matrix.os == 'macos-latest' && secrets.APPLE_TEAM_ID || '' }}
APPLE_ID: ${{ matrix.os == 'macos-latest' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.os == 'macos-latest' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
# SIGN_IDENTITY is a forks-friendly override: when a fork configures
# a Developer ID secret whose CN differs from the upstream default
# (e.g. "Developer ID Application: <Other Name> (<OtherTeamID>)"),
# they set `SIGN_IDENTITY` as a repository secret and codesign in
# build.sh picks it up via `${SIGN_IDENTITY:-...}`. Same matrix.os
# guard so Linux/Windows shards never see it.
SIGN_IDENTITY: ${{ matrix.os == 'macos-latest' && secrets.SIGN_IDENTITY || '' }}
steps:
- uses: actions/checkout@v4
with:
# Full history + tags so the build scripts' annotated-tag guard
# (``git cat-file -t refs/tags/vX.Y.Z`` must return ``tag``) can
# see the tag object, not just the tag ref. The default
# ``actions/checkout@v4`` shallow clone resolves the tag ref
# down to its commit and drops the annotation on the floor,
# which makes an annotated tag look like a lightweight one.
# ``fetch-depth: 0`` alone is not sufficient on v4 —
# ``fetch-tags: true`` is required to pull the tag objects
# themselves, not just the refs.
fetch-depth: 0
fetch-tags: true
# Defense-in-depth: re-fetch tag objects explicitly. On tag-push
# runs the action sometimes creates a local lightweight-style ref
# from the commit SHA even with fetch-tags: true; an explicit
# ``git fetch --tags --force`` guarantees the annotated tag object
# is materialized before the build script's ``git cat-file -t``
# gate runs.
- name: Ensure annotated tag object is fetched
shell: bash
run: git fetch origin --tags --force
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pyinstaller
# —— Download embedded Python interpreter ——
- name: Download python-standalone (macOS/Linux)
if: matrix.os != 'windows-latest'
run: bash scripts/download_python_standalone.sh
- name: Download python-standalone (Windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: .\scripts\download_python_standalone.ps1
# —— macOS: import signing certificate (only when ALL four signing
# secrets are present at job level — see env: block above)
- name: Import Apple signing certificate
if: matrix.os == 'macos-latest' && env.BUILD_CERTIFICATE_BASE64 != '' && env.P12_PASSWORD != '' && env.KEYCHAIN_PASSWORD != '' && env.APPLE_TEAM_ID != ''
run: |
set -euo pipefail
CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
# Always remove the .p12 on EXIT, including failure mid-import:
# `set -e` would otherwise abort before the trailing `rm -f` and
# leave the certificate blob on the runner until cleanup. The
# later `Cleanup keychain` step only handles the keychain itself.
trap 'rm -f "$CERTIFICATE_PATH"' EXIT
echo "${BUILD_CERTIFICATE_BASE64}" | base64 --decode > "$CERTIFICATE_PATH"
security create-keychain -p "${KEYCHAIN_PASSWORD}" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -P "${P12_PASSWORD}" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "${KEYCHAIN_PASSWORD}" "$KEYCHAIN_PATH" >/dev/null
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
# —— macOS: extract the actual signing identity CN from the imported
# keychain so `codesign -s "$SIGN_IDENTITY"` matches whatever
# certificate the fork/release engineer imported, instead of
# a hardcoded maintainer name. Pushes the value into
# $GITHUB_ENV so the next step (Build macOS app) inherits it
# and build.sh sees a non-empty SIGN_IDENTITY (skipping its
# own auto-detect fallback). When no Developer ID identity
# is present (e.g. only Apple Development certs), this step
# leaves SIGN_IDENTITY empty and build.sh's auto-detect
# will pick up whatever else is in the keychain. The same
# gate as Import — runs only when all 4 signing secrets are
# configured, so non-macOS shards and unconfigured runs are
# unaffected.
- name: Extract signing identity from imported keychain
if: matrix.os == 'macos-latest' && env.BUILD_CERTIFICATE_BASE64 != '' && env.P12_PASSWORD != '' && env.KEYCHAIN_PASSWORD != '' && env.APPLE_TEAM_ID != ''
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
DETECTED="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep -E '"Developer ID Application' \
| head -1 \
| sed -E 's/^.*"([^"]+)".*$/\1/' || true)"
if [ -z "${DETECTED:-}" ]; then
# Fallback: ANY codesigning identity (not just Developer ID
# Application). Forks may use Apple Development certs in
# tests; this keeps the build alive long enough to surface
# a clearer error from codesign downstream.
DETECTED="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep -E '^\s+[0-9]+\)' \
| head -1 \
| sed -E 's/^.*"([^"]+)".*$/\1/' || true)"
fi
if [ -n "${DETECTED:-}" ]; then
echo "Detected signing identity: $DETECTED"
echo "SIGN_IDENTITY=$DETECTED" >> "$GITHUB_ENV"
else
echo "WARNING: no codesigning identity found in temp keychain — build.sh will auto-detect or fail with no identity."
fi
# —— macOS build (signed + optionally notarized when secrets are
# present, otherwise unsigned). build.sh reads the same
# env vars from the job-level env block above.
- name: Build macOS app
if: matrix.os == 'macos-latest'
run: |
if [ "${{ needs.release-preflight.outputs.is_prerelease }}" = "true" ]; then
echo "Pre-release tag detected — building unsigned DMG for artifact validation"
OUROBOROS_SIGN=0 bash build.sh
elif [ -n "${BUILD_CERTIFICATE_BASE64:-}" ] && [ -n "${P12_PASSWORD:-}" ] && [ -n "${KEYCHAIN_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
echo "Signing certificate detected — building with codesign + (optional) notarization"
bash build.sh
else
echo "No signing secrets — building unsigned (OUROBOROS_SIGN=0)"
OUROBOROS_SIGN=0 bash build.sh
fi
# —— macOS: cleanup keychain (always, even on build failure) so the
# temporary signing material never persists across runs.
- name: Cleanup keychain
if: always() && matrix.os == 'macos-latest' && env.BUILD_CERTIFICATE_BASE64 != ''
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
security delete-keychain "$KEYCHAIN_PATH" || true
# —— Linux build ——
- name: Build Linux binary
if: matrix.os == 'ubuntu-latest'
run: bash build_linux.sh
# —— Windows build ——
- name: Build Windows executable
if: matrix.os == 'windows-latest'
shell: pwsh
run: .\build_windows.ps1
# —— Upload artifacts ——
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ouroboros-${{ matrix.os }}
path: dist/Ouroboros-*
retention-days: 30
# ──────────────────────────────────────────────────────────────────
# Release: Create GitHub Release with all artifacts
# ──────────────────────────────────────────────────────────────────
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [build, release-preflight, marker-guards, ui-smoke, docker-ui-smoke, docker-portable-test]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: release-artifacts/
merge-multiple: true
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release-artifacts/*
generate_release_notes: true
draft: false
prerelease: ${{ needs.release-preflight.outputs.is_prerelease == 'true' }}