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
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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' }} |