ouroboros/.github/workflows/ci.yml

1760 lines
94 KiB
YAML

# Ouroboros CI — Five-tier cross-platform testing and release pipeline
#
# Tier 1 (Quick): Push to ouroboros or PR targeting ouroboros → Ubuntu-only tests (~1 min)
# Tier 2 (Full): Every PR → Windows/macOS; 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 2.6 (Skill smoke): Push to ouroboros-stable / manual / tag → LIVE OuroborosHub official-skill install smoke (3-OS, ~5 min) + review→grant→enable-persistence flow on one cheap reviewer slot (ubuntu-only step, OPENROUTER_API_KEY, ~$1.2/run)
# Tier 3 (Build+Release): Tag v* → PyInstaller + GitHub Release (~15 min)
# Scheduled (System E2E): Daily cron / manual → KEYLESS tests/system_e2e/ scenario lane (Ubuntu, ≤40 min)
# Scheduled (Live E2E): Nightly cron 03:17 UTC (ouroboros tip) / dispatch input e2e_live=true → PAID devtools/e2e_live stand, $30 cap, OUROBOROS_E2E_LIVE_OPENROUTER_KEY (skipped honestly without it)
#
# Tier 2.5 requires OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY.
# Optional MiniMax / DeepSeek / Cloud.ru / GigaChat rows run when their
# repository secrets exist. The job 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/**'
- 'site/**'
- 'docs/**'
- 'assets/**'
- 'requirements-runtime.lock'
- 'uv.lock'
- 'pyproject.toml'
- '.github/workflows/**'
- '.github/actions/**'
- 'build.sh'
- 'build_linux.sh'
- 'build_windows.ps1'
- 'Dockerfile'
- 'scripts/**'
- 'devtools/**'
- 'packaging/**'
- 'VERSION'
- 'README.md'
- 'CONTRIBUTING.md'
- 'LICENSE'
- '.github/PULL_REQUEST_TEMPLATE.md'
- 'launcher.py'
tags:
- 'v*'
# Fork-safe PR validation: no provider secrets and no pull_request_target.
# Only fork-safe jobs match pull_request refs, including narrow Publish UI proof.
pull_request:
branches: [ouroboros]
# Plain dispatch retains the provider and skill-review jobs and can spend
# money. The separate paid e2e-live stand additionally requires its opt-in
# input (`-f e2e_live=true`); all other dispatch gates stay unchanged.
workflow_dispatch:
inputs:
e2e_live:
description: 'Run the paid live E2E stand ($30 cap; needs the OUROBOROS_E2E_LIVE_OPENROUTER_KEY secret)'
type: boolean
default: false
# Two crons, each owned by exactly ONE job through `github.event.schedule`:
# 04:37 is the keyless system-e2e-mock lane, 03:17 the paid e2e-live stand.
# Off the hour deliberately: GitHub's cron queue is deepest at :00. NOTE for
# any job added later — a scheduled run reports the DEFAULT BRANCH in
# github.ref, so a job gated on a branch ref alone will fire on BOTH crons
# (see integration-test), and a job gated on the bare event name fires twice
# a night — bind it to its own cron string.
schedule:
- cron: '37 4 * * *'
- cron: '17 3 * * *'
# Note: GitHub Actions evaluates `branches` + `paths` together but `tags`
# separately — a tag push matching `v*` will trigger regardless of paths.
# Read-only is sufficient for tests and fork PRs. The release job overrides
# this narrowly with contents: write when publishing a tag.
permissions:
contents: read
jobs:
# ──────────────────────────────────────────────────────────────────
# Tier 1: Quick tests on Ubuntu (push to ouroboros or PR targeting it)
# ──────────────────────────────────────────────────────────────────
quick-test:
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/ouroboros')
|| (github.event_name == 'pull_request' && github.base_ref == 'ouroboros')
|| github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
env:
# This job installs pytest-xdist/pytest-timeout below, so the preflight
# gate's own real-spawn regressions MUST execute here. Without the flag
# they self-conceal: `requires_preflight_plugins` skips them when the
# interpreter lacks the plugins, and the control test that would have said
# so carried the same marker — so a provisioning miss looked like a green
# run with a dozen quiet skips. The flag turns that into one loud failure.
OUROBOROS_PREFLIGHT_REQUIRE_PLUGINS: "1"
# Same contract for the gate's node lane: this job provisions node 22, so
# the real-spawn node-lane tests must run here rather than skip.
OUROBOROS_PREFLIGHT_REQUIRE_NODE: "1"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ./.github/actions/setup-python-env
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 11.9.0
run_install: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'pnpm'
cache-dependency-path: site/pnpm-lock.yaml
- name: Verify generated Pages output
run: |
pnpm --dir site install --frozen-lockfile
pnpm --dir site build
test -z "$(git status --porcelain --untracked-files=all -- docs/)"
git diff --exit-code -- docs/
- name: Lint (deterministic F-rule gate — catches the NameError-under-except class)
run: python -m ruff check . --select F
# The hermetic commit gate runs this exact suite (ouroboros/preflight_node.py);
# keeping the step here keeps gate and CI mirrored in both directions.
- name: Run browser-module tests (node --test — mirrored by the hermetic commit gate)
shell: bash
run: cd web && node --test tests/*.test.js
# ESLint `no-undef` is the second, independent layer of the browser no-undef
# gate (owner decision D-13): the hermetic commit gate keeps the dependency-free
# acorn walker in the suite above; this layer is CI-only, lockfile-frozen
# (`npm ci` on web/package-lock.json) and exact-pinned in web/package.json.
- name: Lint browser modules (ESLint no-undef — CI-only second layer of the acorn gate)
shell: bash
run: cd web && npm ci --no-audit --no-fund && npm run lint:undef
# NOTE: a command-line `-m` REPLACES the pyproject `addopts` markexpr, so the default
# `not integration and not browser ...` exclusions must be repeated here, then ANDed with
# the serial split. --timeout guards against a hung test blocking the whole job.
- name: Run tests (parallel — excludes the costly marker lanes AND the serial real-process suites)
run: python -m pytest tests/ -m "not serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -n auto --dist loadscope --max-worker-restart=0 --timeout=300 --timeout-method=thread -q --tb=short
- name: Run tests (serial — real subprocess/port/global-state suites that flake under -n)
run: python -m pytest tests/ -m "serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -q --tb=short
# OFFICIAL-CI-ONLY enforcement of the repository size gates: manifest
# exactness against this tree plus the pairwise base-vs-tip shrink-only
# transition. Local runs exclude the `size_ratchet` marker and surface
# the same validator findings as warnings (docs/DEVELOPMENT.md "Pytest
# marker lanes"). Deliberately NOT the `-m "..."` double-quoted form:
# tests/test_preflight_runner.py pins that form to the two-pass split
# above. The base env: PR -> the PR base SHA; push -> the previous
# branch tip. An all-zeros (new-branch/tag) or unresolvable base
# degrades to the tip's tree-verified parent manifest — never a skip;
# a resolvable base without the manifest fails closed.
- name: Run tests (size-ratchet lane — blocking here, warning-only locally)
env:
OURO_SIZE_RATCHET_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: python -m pytest tests/ -m size_ratchet -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
# ──────────────────────────────────────────────────────────────────
# Benchmark methodology tests (owner decision R42). The devtools/benchmarks
# suites live OUTSIDE pyproject's `testpaths = ["tests"]`, so the full-suite
# passes above never collect them; this job runs them explicitly beside
# their in-tree sibling tests/test_devtools_benchmarks.py, under the same
# canonical marker exclusions, in one serial pass (the serial-marked rows
# are included; nothing here needs -n). Deterministic and hermetic: no
# provider secret, no docker, no `harbor` / `inspect_ai` (the suites stub
# them or never import them); the OUROBOROS_* roots point at a runner-temp
# data root the way betterleaks-platform-smoke pins its own. Ubuntu-only:
# the terminal_bench methodology targets a Linux harness (harbor + docker)
# and its suites have never run on the Windows/macOS matrix.
# ──────────────────────────────────────────────────────────────────
benchmark-methodology:
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/ouroboros')
|| (github.event_name == 'pull_request' && github.base_ref == 'ouroboros')
|| github.event_name == 'workflow_dispatch'
|| github.ref == 'refs/heads/ouroboros-stable'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-env
# `devtools/benchmarks` as a directory IS the `devtools/benchmarks/**/test_*.py`
# glob (pyproject `python_files`): no shell globstar, no list to maintain
# when a bench adds a suite. The roots ride the STEP env: the `runner`
# context is step-only (the same shape as betterleaks-platform-smoke).
- name: Run benchmark methodology tests (tests/test_devtools_benchmarks.py + devtools/benchmarks/**/test_*.py)
env:
OUROBOROS_APP_ROOT: ${{ runner.temp }}/ouroboros-benchmark-methodology
OUROBOROS_DATA_DIR: ${{ runner.temp }}/ouroboros-benchmark-methodology/data
OUROBOROS_SETTINGS_PATH: ${{ runner.temp }}/ouroboros-benchmark-methodology/data/settings.json
run: python -m pytest tests/test_devtools_benchmarks.py devtools/benchmarks -m "not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" --timeout=300 --timeout-method=thread -q --tb=short
# ──────────────────────────────────────────────────────────────────
# Tier 2: Ordinary tests on PR Windows/macOS; full 3-OS stable/manual/tag matrix
# ──────────────────────────────────────────────────────────────────
full-test:
if: |
github.ref == 'refs/heads/ouroboros-stable'
|| github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
|| (github.event_name == 'pull_request' && github.base_ref == 'ouroboros')
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON(github.event_name == 'pull_request' && '["windows-latest","macos-latest"]' || '["ubuntu-latest","windows-latest","macos-latest"]') }}
runs-on: ${{ matrix.os }}
env:
# Same contract as quick-test: this job provisions the parallel-pass
# plugins, so the preflight gate's real-spawn regressions must run rather
# than skip themselves into invisibility.
OUROBOROS_PREFLIGHT_REQUIRE_PLUGINS: "1"
# ...and node 22 below, so the gate's real-spawn node-lane tests must run.
OUROBOROS_PREFLIGHT_REQUIRE_NODE: "1"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ./.github/actions/setup-python-env
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
# The hermetic commit gate runs this exact suite (ouroboros/preflight_node.py);
# `shell: bash` keeps the glob working on the Windows runner too.
- name: Run browser-module tests (node --test — mirrored by the hermetic commit gate)
shell: bash
run: cd web && node --test tests/*.test.js
# ESLint `no-undef` is the second, independent layer of the browser no-undef
# gate (owner decision D-13): the hermetic commit gate keeps the dependency-free
# acorn walker in the suite above; this layer is CI-only, lockfile-frozen
# (`npm ci` on web/package-lock.json) and exact-pinned in web/package.json.
- name: Lint browser modules (ESLint no-undef — CI-only second layer of the acorn gate)
shell: bash
run: cd web && npm ci --no-audit --no-fund && npm run lint:undef
# A command-line `-m` REPLACES the pyproject addopts markexpr, so repeat the default
# marker-lane exclusions and AND them with the serial split. --timeout guards hangs.
- name: Run tests (parallel — excludes the costly marker lanes AND the serial real-process suites)
run: python -m pytest tests/ -m "not serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -n auto --dist loadscope --max-worker-restart=0 --timeout=300 --timeout-method=thread -q --tb=short
- name: Run tests (serial — real subprocess/port/global-state suites that flake under -n)
run: python -m pytest tests/ -m "serial and not integration and not browser and not ui_browser and not ui_browser_docker and not portable_detail and not skill_smoke and not size_ratchet" -q --tb=short
# Same size-ratchet enforcement contract as quick-test (see its comment).
# PRs carry their base SHA; stable pushes carry event.before; dispatch runs leave
# the env empty and tag pushes carry an all-zeros before — both degrade to
# the tip's parent manifest (never a skip: a skip would let a recreated
# branch grandfather debt in one green run).
- name: Run tests (size-ratchet lane — blocking here, warning-only locally)
env:
OURO_SIZE_RATCHET_BASE_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
run: python -m pytest tests/ -m size_ratchet -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
# Fork-safe PR proof for the one native secret-scanner dependency. This job
# carries no provider or repository secret: it exercises the explicit pinned
# installer, managed resolver, license/version checks, and redaction-safe real
# scanner smoke on each supported desktop OS family.
betterleaks-platform-smoke:
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/ouroboros')
|| (github.event_name == 'pull_request' && github.base_ref == 'ouroboros')
|| github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
env:
PYTHONUTF8: "1"
OUROBOROS_BETTERLEAKS_REQUIRE_REAL: "1"
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: ./.github/actions/setup-python-env
- name: Install exact Betterleaks runtime
env:
OUROBOROS_DATA_DIR: ${{ runner.temp }}/ouroboros-betterleaks-data
shell: bash
run: python -m ouroboros.betterleaks_runtime install
- name: Smoke managed Betterleaks runtime
env:
OUROBOROS_DATA_DIR: ${{ runner.temp }}/ouroboros-betterleaks-data
shell: bash
run: python scripts/betterleaks_platform_smoke.py --managed-runtime
- name: Run full pinned Betterleaks scanner contract
env:
OUROBOROS_DATA_DIR: ${{ runner.temp }}/ouroboros-betterleaks-data
shell: bash
run: python -m pytest tests/test_skill_publish_scanner_real.py -q
# ──────────────────────────────────────────────────────────────────
# Tier 2.5: Integration tests against real provider APIs
# Triggered on push to main / ouroboros / ouroboros-stable, manual,
# or tag v*. OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY
# are required in the official trusted job; MiniMax / DeepSeek / Cloud.ru /
# GigaChat credentials are optional and their absent rows remain visible as skips.
# The `integration` pytest marker controls inclusion via `-m integration`.
# Confirmed provider-contract failures block release-preflight; the test
# classifier keeps quota/rate-limit/5xx/timeout outcomes inconclusive.
# ──────────────────────────────────────────────────────────────────
integration-test:
# The leading schedule guard is what keeps the nightly cron above OFF this
# paid lane: a scheduled run carries the default branch in github.ref, which
# three of the conditions below already match.
if: |
github.event_name != 'schedule'
&& (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: ./.github/actions/setup-python-env
- 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 }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
CLOUDRU_FOUNDATION_MODELS_API_KEY: ${{ secrets.CLOUDRU_FOUNDATION_MODELS_API_KEY }}
CLOUDRU_FOUNDATION_MODELS_BASE_URL: ${{ secrets.CLOUDRU_FOUNDATION_MODELS_BASE_URL }}
GIGACHAT_CREDENTIALS: ${{ secrets.GIGACHAT_CREDENTIALS }}
run: python -m pytest tests/test_provider_integration.py -m integration -q -rs --tb=short
# ──────────────────────────────────────────────────────────────────
# Tier 2.6: Official-skill install smoke against the LIVE OuroborosHub
# catalog (https://raw.githubusercontent.com/razzant/OuroborosHub/main/
# catalog.json). Purpose: catch regressions in OUR runtime — the
# ouroboroshub client, skill_loader/manifest contract, skill_preflight,
# and the isolated-deps installer — against the real published catalog.
# Red = investigate (either our runtime regressed or the hub published a
# broken official skill); there is deliberately NO fallback-skip on
# network failure, and gating the release on live external services
# (GitHub raw, PyPI, DuckDuckGo, wttr.in, OpenRouter) is a deliberate
# owner decision. Provider integration is now a separate release-preflight
# dependency with typed inconclusive outcomes for transient outages. Runs
# the `skill_smoke` pytest marker
# (pyproject.toml) as serial pytest invocations: real network installs +
# real pip installs into per-skill isolated envs are not xdist-safe —
# never add -n here (the lane's tests are also kept out of the quick/full
# markexprs and must never carry the `serial` marker; see
# docs/DEVELOPMENT.md "Pytest marker lanes").
#
# The job is TWO pytest steps as a SECURITY BOUNDARY, not test taxonomy,
# and the ORDER is part of the boundary: the review-flow step runs FIRST,
# carrying OPENROUTER_API_KEY in a fresh pytest process that never
# imports downloaded plugin code (installs + review read payload bytes;
# nothing executes them, and the isolated-deps pip subprocess gets a
# scrubbed allowlist env). Only AFTER the secret-bearing step finishes
# does the install/preflight/deps/command step import downloaded
# (sha-verified official) plugin code in-process — secret-free, so the
# runner has never executed payload code while the secret was present.
# The review step runs Ouroboros's own skill review on ONE cheap
# stochastic reviewer slot (google/gemini-3.5-flash, low effort — the
# test pins env; production reviewer defaults stay strong and untouched),
# ubuntu-only (an LLM verdict is OS-independent), for a 4-skill subset.
# Paid lane: ~$1.2/run (~$2.4 with verdict retries). A missing secret is
# a hard red by owner directive — forks/mirrors without it stay red.
# ──────────────────────────────────────────────────────────────────
skill-smoke:
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 }}
timeout-minutes: 45
env:
# Windows runners default stdout/subprocess decoding to cp1252; live
# DDG/wttr error text and pip/venv diagnostics can be non-ASCII, and a
# UnicodeEncodeError in a retry/print path would mask the real failure.
PYTHONUTF8: "1"
steps:
- uses: actions/checkout@v4
with:
# This job executes downloaded (sha-verified official) plugin code
# and never pushes; do not leave GITHUB_TOKEN in .git/config.
persist-credentials: false
- uses: ./.github/actions/setup-python-env
# Tier 6 (review flow) FIRST — see the job comment: the secret-bearing
# step must precede any step that executes downloaded plugin code.
# Ubuntu-only: the LLM verdict is OS-independent. --timeout=2100
# covers one review + one fresh verdict retry + the post-review COLD
# a2a dependency install (this process has its own temp data dir, so
# Tier 4's venv is never warm here; production review→deps order).
# The 45-min job bound, not the per-test cap, is the effective
# worst-case limiter if several skills go pathological at once.
- name: Run official-skill review flow smoke (LLM review, ubuntu only)
if: matrix.os == 'ubuntu-latest'
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: python -m pytest tests/ -m skill_smoke -k "review_grants_and_enable" --timeout=2100 --timeout-method=thread -q -s --tb=long
# -s (no capture): the lane logs provenance (catalog digest, skill
# versions, resolved pip sets) that must reach the CI log on green AND
# red runs — under default capture, session-fixture prints surface only
# when the FIRST test fails. A single serial invocation makes capture-off
# safe and streams progress inside the job bound. Per-test `timeout`
# marks govern each phase (900s covers the cold a2a venv+pip); the CLI
# --timeout is the fallback default for future unmarked tests.
# NO provider secret in this step: it imports downloaded plugin code.
# !cancelled(): a Tier 6 red must not mask this shard's Tier 1-5 signal
# (the secret lives only in the previous step's env either way).
- name: Run official-skill install smoke (real network, serial)
if: ${{ !cancelled() }}
run: python -m pytest tests/ -m skill_smoke -k "not review_grants_and_enable" --timeout=900 --timeout-method=thread -q -s --tb=long
marker-guards:
if: |
github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-env
- 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
- name: Guard non-empty serial marker lane
run: |
set -euo pipefail
# The full-suite CI runs a PARALLEL pass (-m "not serial ...") + a SERIAL pass
# (-m serial). If a refactor empties _SERIAL_TEST_FILES (tests/conftest.py) the
# serial pass would silently collect 0 tests. `--collect-only -m serial` reproduces
# that selection: an empty lane makes pytest exit 5 (EXIT_NOTESTSCOLLECTED), which
# `set -o pipefail` surfaces through `| tee` to fail this step. The positive anchor
# grep is the working assertion — under -q pytest prints NO "no tests collected"
# text, so a `! grep` on it is a false-green no-op; instead we pin a known-stable
# serial file as a deliberate canary (rename it -> update this line).
python -m pytest tests/ --collect-only -m serial -q | tee /tmp/serial-collect.txt
grep -q "tests/test_workspace_executor.py" /tmp/serial-collect.txt
- name: Guard non-empty skill_smoke marker lane
run: |
set -euo pipefail
# Same positive-anchor pattern as the serial guard above: under -q an
# empty lane exits 5 through pipefail, and the file grep pins the
# canary so a marker refactor cannot silently empty the lane.
python -m pytest tests/ --collect-only -m skill_smoke -q | tee /tmp/skill-smoke-collect.txt
grep -q "tests/test_skill_smoke_official.py" /tmp/skill-smoke-collect.txt
- name: Guard non-empty size_ratchet marker lane
run: |
set -euo pipefail
# Same positive-anchor pattern as the skill_smoke guard above. This
# lane is the ONLY blocking surface for the size gates (local runs
# merely warn), so silently emptying it would retire size
# enforcement altogether.
python -m pytest tests/ --collect-only -m size_ratchet -q | tee /tmp/size-ratchet-collect.txt
grep -q "tests/test_smoke.py" /tmp/size-ratchet-collect.txt
grep -q "tests/test_repo_health_smoke.py" /tmp/size-ratchet-collect.txt
ui-smoke:
if: |
github.event_name == 'pull_request'
|| github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-env
- name: Install UI smoke Chromium
run: python -m playwright install --with-deps chromium
- name: Install full UI smoke WebKit
if: github.event_name != 'pull_request'
run: python -m playwright install --with-deps webkit
- name: Run Publish admission browser proof
if: github.event_name == 'pull_request'
env:
OUROBOROS_RUN_UI_SMOKE: "1"
OUROBOROS_EXPECT_BROWSER_ENGINES: chromium
run: python -m pytest tests/test_skill_publish_browser.py -o addopts="" -m ui_browser -q --tb=short
- name: Run host UI smoke
if: github.event_name != 'pull_request'
env:
OUROBOROS_RUN_UI_SMOKE: "1"
run: python -m pytest tests/ -m ui_browser -q --tb=short
- name: Run browser tools Chromium/WebKit smoke
if: github.event_name != 'pull_request'
env:
OUROBOROS_EXPECT_BROWSER_ENGINES: chromium,webkit
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: ./.github/actions/setup-python-env
- name: Install UI smoke browser binaries
run: python -m playwright install --with-deps chromium webkit
- 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
- name: Run Docker browser tools Chromium/WebKit smoke
run: |
docker run --rm --entrypoint sh \
-e OUROBOROS_EXPECT_BROWSER_ENGINES=chromium,webkit \
ouroboros-web:test -c \
"PLAYWRIGHT_BROWSERS_PATH=0 python -m pytest tests/test_browser_tools_smoke.py -m browser -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"
# ──────────────────────────────────────────────────────────────────
# Scheduled: the KEYLESS system_e2e scenario lane (tests/system_e2e/).
# Those tests carry `integration` + `serial` AND the OUROBOROS_E2E_DEEP env
# gate, so no other job in this file can reach them — without this one the
# suite would be proof nobody executes. Deliberately NOT a push/PR lane
# (owner 9A, replacing the plan §8 PR lane): each scenario spawns a real
# isolated server, so the cost belongs on a nightly, not on every review.
# No provider secrets: the lane is keyless by construction and this job
# names none, which is the only way a job gets one. The four OUROBOROS_*
# roots point into runner.temp so no scenario can reach a real install.
# Red here = investigate; there is no fallback-skip.
# ──────────────────────────────────────────────────────────────────
system-e2e-mock:
# Also on a release tag: the release bar must not skip the 57-scenario lane
# (owner decision, batch №13 item 4, 2026-09-02). Bound to ITS cron string:
# the 03:17 cron belongs to e2e-live and must not run this lane a second
# time each night.
if: |
(github.event_name == 'schedule' && github.event.schedule == '37 4 * * *')
|| github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-env
# -o addopts="" replaces the pyproject default markexpr wholesale: the
# lane's own `integration` marker is exactly what that default excludes.
- name: Run the keyless system E2E scenario lane (real isolated servers)
env:
OUROBOROS_E2E_DEEP: mock
OUROBOROS_APP_ROOT: ${{ runner.temp }}/ouroboros-system-e2e/app
OUROBOROS_REPO_DIR: ${{ runner.temp }}/ouroboros-system-e2e/repo
OUROBOROS_DATA_DIR: ${{ runner.temp }}/ouroboros-system-e2e/data
OUROBOROS_SETTINGS_PATH: ${{ runner.temp }}/ouroboros-system-e2e/data/settings.json
run: python -m pytest tests/system_e2e/ -o addopts="" -q
# The cancellation E-suite mock lane (18 scenarios + 4 mock-only) ran only on
# operator hosts before 7.0; on the same isolated roots it is keyless too
# (owner decision, batch №13 item 16, 2026-09-02).
- name: Run the cancellation E-suite mock lane
env:
OUROBOROS_E2E_CANCEL: mock
OUROBOROS_APP_ROOT: ${{ runner.temp }}/ouroboros-e2e-cancel/app
OUROBOROS_REPO_DIR: ${{ runner.temp }}/ouroboros-e2e-cancel/repo
OUROBOROS_DATA_DIR: ${{ runner.temp }}/ouroboros-e2e-cancel/data
OUROBOROS_SETTINGS_PATH: ${{ runner.temp }}/ouroboros-e2e-cancel/data/settings.json
run: python -m pytest tests/test_e2e_cancellation_scenarios.py -o addopts="" -q
# ──────────────────────────────────────────────────────────────────
# Scheduled (paid): the LIVE E2E stand (devtools/e2e_live/run_live_lanes.py)
# exactly as the operator runs it — a clean detached seed of the checked-out
# commit, one isolated real server per lane, a real OpenRouter key, the
# post-task evolution cycle with its re-exec restart (--self-mod), the same
# flag set as the operator launcher minus the run size. Two triggers only,
# never push, PR or tag: (1) a dispatch that OPTS IN through the `e2e_live`
# input — a plain `gh workflow run CI` (the pre-tag matrix) never runs this
# lane; (2) the nightly 03:17 UTC cron (its own cron string, see the schedule
# block). A scheduled run executes the workflow file of the DEFAULT branch
# (main, the promoted release line) and reports that branch in
# github.sha/github.ref, so the nightly checks out and seeds the `ouroboros`
# branch TIP — the development line the nightly is meant to certify — while
# a dispatch seeds the dispatched github.sha; the manifest's seed block names
# the resolved commit either way. The key is the repository secret
# OUROBOROS_E2E_LIVE_OPENROUTER_KEY
# (the owner creates it; nothing in this repo writes a secret to GitHub):
# without it the job is SKIPPED honestly — one step-summary line, exit 0 —
# never red and never a pretend run. The secret context is illegal inside
# `if:`, so the presence gate is the job-level non-secret env
# HAS_E2E_LIVE_KEY (the same shape as HAS_APPLE_SIGNING in `build`).
#
# Run size — the stand's own admission arithmetic, computed from the code at
# this base (devtools/e2e_live/run_live_lanes.py `RunBudget.reservation`):
# reservation(attempt) = max(0.01, per_task_usd x (root_tasks + 1 if --self-mod and the scenario absorbs else root_tasks))
# root_tasks = 1 for SM1 and SW1, 2 for SK1 (scenarios.py); --self-mod
# adds one root for the post-task evolution cycle of the
# scenario that absorbs (SM1; SW1/SK1 pin promotion off)
# and an attempt is admitted only while spent + reserved(in flight) +
# reservation <= --total-budget; one that can never fit is recorded not_run
# and fails the verdict. Worst case (every attempt spends its whole
# reservation) the full operator set SM1,SW1,SK1 x3 with --self-mod needs
# per_task x (2 + 1 + 2) x 3 = 15 x per_task, and the product's default
# per-task cap (settings_defaults OUROBOROS_PER_TASK_COST_USD = 50) makes a
# SINGLE SM1 attempt reserve 50 x (1 + 1) = $100 > $30: nothing would be
# admissible. So the fence is --per-task-usd 15 = 30 / 2, the largest value
# under which one single-root self-mod attempt fits: SM1 x1 reserves exactly
# 15 x (1 + 1) = $30 = the cap (the full set: 15 x 15 = $225). Any second
# attempt (SW1 $15, SK1 15 x 2 = $30) cannot fit beside it by that rule,
# hence ONE lane, ONE attempt, SM1 only — the largest feasible subset,
# named in the job title. Only if SM1's evolution root ever left the rule
# would SM1 + SW1 (15 + 15 = 30) become feasible together: revisit
# --scenarios then, keep the arithmetic here (the feasibility test and the
# summary-header pin re-derive it from the code and trip on a rule change).
# --min-credit-usd defaults to --total-budget: the key needs >= $30 headroom
# (min of key limit remaining and account credits) or the stand refuses
# with a typed credit_preflight refusal in run_manifest.json.
#
# Runner shape: the commit gate's hermetic pytest pass runs INSIDE the lane
# server with OUROBOROS_PREFLIGHT_TEST_WORKERS = 16 // lanes = 16 workers
# on a 4-vCPU runner (slower than the operator host, not wrong), the node
# lane of that gate needs node 22, and the UI probe / the agent's browser
# tool need Playwright Chromium — the same provisioning as ui-smoke, minus
# webkit. The run root is under runner.temp (the stand refuses a root inside
# the repo), the lane settings file (0600, carries the key) is NEVER
# uploaded: the artifact globs name the manifest, the result index, each
# lane's result.json and its screenshots only — no `lanes/**`.
# ──────────────────────────────────────────────────────────────────
e2e-live:
name: e2e-live (SM1 x1 — largest subset feasible under the $30 cap)
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.e2e_live == 'true')
|| (github.event_name == 'schedule' && github.event.schedule == '17 3 * * *')
runs-on: ubuntu-latest
# One SM1 lane: the task (--task-timeout 2400), the self-mod evolution cycle
# as a second root task, the absorb wait with the re-exec restart, and two
# hermetic preflight suites on a 4-vCPU runner — well inside this bound.
timeout-minutes: 150
env:
HAS_E2E_LIVE_KEY: ${{ secrets.OUROBOROS_E2E_LIVE_OPENROUTER_KEY != '' && 'true' || 'false' }}
steps:
- uses: actions/checkout@v4
with:
# Nightly: the `ouroboros` branch tip (the schedule itself fires on
# the default branch, main); dispatch: the dispatched sha.
ref: ${{ github.event_name == 'schedule' && 'ouroboros' || github.sha }}
# Full history + tags: the seed's `git describe` provenance and the
# SM1 release admission gate read them.
fetch-depth: 0
# The model under test executes real code and commits inside its
# lane clone; it must never find GITHUB_TOKEN in .git/config.
persist-credentials: false
- name: Report the missing secret (skip, not red)
if: env.HAS_E2E_LIVE_KEY != 'true'
run: |
echo "skipped: secret OUROBOROS_E2E_LIVE_OPENROUTER_KEY not configured" >> "$GITHUB_STEP_SUMMARY"
echo "skipped: secret OUROBOROS_E2E_LIVE_OPENROUTER_KEY not configured"
- uses: ./.github/actions/setup-python-env
if: env.HAS_E2E_LIVE_KEY == 'true'
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
if: env.HAS_E2E_LIVE_KEY == 'true'
with:
node-version: '22'
- name: Install the UI probe browser (Playwright Chromium)
if: env.HAS_E2E_LIVE_KEY == 'true'
run: python -m playwright install --with-deps chromium
# The key reaches the stand by NAME (--key-env) and this step's env only;
# the stand redacts the run-level settings template and writes the value
# solely into the lane's 0600 settings file, disclosed by fingerprint.
# --seed HEAD is the checked-out commit (the ouroboros tip on schedule,
# the dispatched sha otherwise — GITHUB_SHA names the default branch on
# a schedule); the stand resolves it and records the sha in the manifest.
- name: Run the live E2E stand (SM1 x1, $30 cap, self-mod)
if: env.HAS_E2E_LIVE_KEY == 'true'
env:
OUROBOROS_E2E_LIVE_OPENROUTER_KEY: ${{ secrets.OUROBOROS_E2E_LIVE_OPENROUTER_KEY }}
run: |
python -m devtools.e2e_live.run_live_lanes \
--source-repo "$GITHUB_WORKSPACE" --seed HEAD --out "$RUNNER_TEMP/e2e_live" \
--scenarios SM1 --attempts 1 --pass-of 1 --lanes 1 \
--total-budget 30 --per-task-usd 15 --task-timeout 2400 --self-mod \
--key-env OUROBOROS_E2E_LIVE_OPENROUTER_KEY --watch-interval 60
# The manifest is written at admission and finalized on EVERY exit path,
# so the summary reads the run's own record — and must render EVERY shape
# of it: `extra.scenarios` is the requested id LIST until the verdict dict
# is written on completion, so a typed refusal (credit_preflight,
# key_unusable, seed_materialize) or a crash leaves the list; verdicts
# render only for a dict, the refusal/error fields otherwise. The step
# reports, it never fails on its own: the stand's exit is the verdict.
- name: Summarize the run
if: always() && env.HAS_E2E_LIVE_KEY == 'true'
run: |
python - <<'PY'
import json, os, pathlib
manifest = pathlib.Path(os.environ["RUNNER_TEMP"]) / "e2e_live" / "run_manifest.json"
lines = ["## e2e-live: SM1 x1 — the largest subset feasible under the $30 cap "
"(per-task $15 x (roots + the self-mod evolution root): SM1 x1 = $30; "
"the full SM1/SW1/SK1 x3 set needs $225)"]
try:
if not manifest.exists():
lines.append("no run_manifest.json: the stand never reached admission (see the step log)")
else:
doc = json.loads(manifest.read_text(encoding="utf-8"))
extra = doc.get("extra") if isinstance(doc.get("extra"), dict) else {}
seed = doc.get("seed") if isinstance(doc.get("seed"), dict) else {}
lines.append(f"outcome: {extra.get('outcome')} (exit {extra.get('exit_code')}); "
f"seed {extra.get('seed_describe') or extra.get('seed_head') or seed.get('resolved_sha') or extra.get('seed_ref')}; "
f"model {extra.get('effective_model') or 'not applied'}")
# The run's OWN reservations come from the manifest's budget_preflight
# (the stand's ledger at admission), never from arithmetic kept in text.
preflight = extra.get("budget_preflight")
if isinstance(preflight, dict) and isinstance(preflight.get("scenarios"), list):
lines.append("reservations: " + "; ".join(
f"{row.get('scenario')} ${float(row.get('reservation_usd') or 0):.2f} x {row.get('attempts')} "
f"({row.get('root_tasks')} root{' + evolution' if preflight.get('self_mod') else ''})"
for row in preflight["scenarios"] if isinstance(row, dict))
+ f"; worst case ${float(preflight.get('worst_case_usd') or 0):.2f} of cap "
f"${float(preflight.get('cap_usd') or 0):.2f} at per-task ${float(preflight.get('per_task_usd') or 0):.2f}")
for key in ("refusal", "error", "credit_preflight", "stop_reason"):
if extra.get(key):
lines.append(f"{key}: {json.dumps(extra[key], ensure_ascii=False, default=str)}")
scenarios = extra.get("scenarios")
if isinstance(scenarios, dict):
for sid, verdict in scenarios.items():
lines.append(f"{sid}: {json.dumps(verdict, ensure_ascii=False, default=str)}")
else:
lines.append(f"no verdicts (the run did not complete); requested scenarios: {scenarios}")
budget = extra.get("budget")
if isinstance(budget, dict) and budget:
lines.append(f"budget: spent ${float(budget.get('spent_usd') or 0):.2f} of cap "
f"${float(budget.get('cap_usd') or 0):.2f}; refusals {len(budget.get('refusals') or [])}")
if extra.get("self_mod"):
lines.append(f"self_mod: {json.dumps(extra['self_mod'], ensure_ascii=False, default=str)}")
except Exception as exc: # the summary reports; it never fails the job on its own
lines.append(f"summary could not read run_manifest.json ({type(exc).__name__}: {exc})")
text = "\n".join(lines) + "\n"
print(text, end="")
path = os.environ.get("GITHUB_STEP_SUMMARY")
if path:
with open(path, "a", encoding="utf-8") as fh:
fh.write(text)
PY
- name: Upload run artifacts (manifest, lane results, screenshots)
if: always() && env.HAS_E2E_LIVE_KEY == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: e2e-live-run
path: |
${{ runner.temp }}/e2e_live/run_manifest.json
${{ runner.temp }}/e2e_live/result_index.jsonl
${{ runner.temp }}/e2e_live/lanes/*/result.json
${{ runner.temp }}/e2e_live/lanes/*/shots/*.png
if-no-files-found: warn
retention-days: 30
# ──────────────────────────────────────────────────────────────────
# Tier 3: Build & Release (tag push only)
# ──────────────────────────────────────────────────────────────────
release-preflight:
if: startsWith(github.ref, 'refs/tags/v')
# system-e2e-mock joined the release bar with the v7 campaign (batch №13 item 4).
needs: [full-test, integration-test, system-e2e-mock]
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: v7next adoption release bar
# The campaign ledger's release gate (every transplanted delta done or an
# explicit post-release row with its authority) runs on the tag path itself,
# not only inside the pytest wrapper (batch №13 item 4).
run: python scripts/v7next_adoption.py --release
- 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]
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
artifact: dmg
proof_id: macos-arm64
syft_archive: syft_1.50.0_darwin_arm64.tar.gz
syft_sha256: e32fdb9d47823fa633748a1efca2528fd77c37469ea93c9e40ab835da44e4cce
syft_binary: syft
- os: ubuntu-latest
artifact: tar.gz
proof_id: linux-x86_64
syft_archive: syft_1.50.0_linux_amd64.tar.gz
syft_sha256: bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788
syft_binary: syft
- os: windows-latest
artifact: zip
proof_id: windows-x64
syft_archive: syft_1.50.0_windows_amd64.zip
syft_sha256: 815ee6973ec5dff6a671d7f41b0e78835a8c45b91d5a39f4743ea1cee833d3be
syft_binary: syft.exe
runs-on: ${{ matrix.os }}
env:
OUROBOROS_RELEASE_TAG: ${{ github.ref_name }}
# Only non-secret booleans are job-wide. Credential values are exposed
# to the exact first-party steps that import, sign, or notarize.
HAS_APPLE_SIGNING: ${{ matrix.os == 'macos-latest' && secrets.BUILD_CERTIFICATE_BASE64 != '' && secrets.P12_PASSWORD != '' && secrets.KEYCHAIN_PASSWORD != '' && secrets.APPLE_TEAM_ID != '' && 'true' || 'false' }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # 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
# The embedded repo.bundle records the managed source branch the packaged
# app updates from, and scripts/build_repo_bundle.py refuses a HEAD that
# branch does not contain. A release tag on the live line resolves to
# `ouroboros`; a pre-release cut from another branch (the v7 campaign's
# `ouroboros_v7next`, owner batch №13 item 14) resolves to the ONE remote
# branch that contains the tag — ambiguity or none fails the build loudly
# rather than embedding a branch the tag is not on.
- name: Resolve the managed source branch of this tag
shell: bash
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/ouroboros; then
branch=ouroboros
else
# Portable across bash 3.2 (macOS runners): no bash-4-only array builtins.
branches="$(git branch -r --contains HEAD --format='%(refname:short)' \
| sed -n 's#^origin/##p' | grep -v '^HEAD$' || true)"
count="$(printf '%s\n' "$branches" | sed '/^$/d' | wc -l | tr -d ' ')"
if [ "$count" != "1" ]; then
echo "managed source branch for ${GITHUB_REF_NAME}: expected exactly one remote branch containing HEAD, got: ${branches:-none}" >&2
exit 1
fi
branch="$(printf '%s\n' "$branches" | sed '/^$/d')"
fi
echo "managed source branch: $branch"
echo "OUROBOROS_MANAGED_SOURCE_BRANCH=$branch" >> "$GITHUB_ENV"
# 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: ./.github/actions/setup-python-env
with:
profile: build
# —— 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 signing inputs
# are present. The values exist only inside this step.
- name: Import Apple signing certificate
if: matrix.os == 'macos-latest' && env.HAS_APPLE_SIGNING == 'true'
env:
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
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.HAS_APPLE_SIGNING == 'true'
env:
SIGN_IDENTITY_OVERRIDE: ${{ secrets.SIGN_IDENTITY }}
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
DETECTED="${SIGN_IDENTITY_OVERRIDE:-}"
if [ -z "$DETECTED" ]; then
DETECTED="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep -E '"Developer ID Application' \
| head -1 \
| sed -E 's/^.*"([^"]+)".*$/\1/' || true)"
fi
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). Notarization values exist only
# inside this first-party build step.
- name: Build macOS app
if: matrix.os == 'macos-latest'
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
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 [ "$HAS_APPLE_SIGNING" = "true" ]; 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.HAS_APPLE_SIGNING == 'true'
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
# The proof chain begins at the final archive, after the build scripts
# have finished signing, notarizing, and packaging it.
- name: Locate final release archive
id: release_asset
shell: bash
run: python scripts/release_proof.py locate --directory dist --github-output "$GITHUB_OUTPUT"
- name: Smoke final macOS DMG
id: smoke_macos
if: matrix.os == 'macos-latest'
shell: bash
run: |
set -euo pipefail
MOUNT="$RUNNER_TEMP/ouroboros-dmg-${GITHUB_RUN_ATTEMPT}"
HOME_DIR="$RUNNER_TEMP/ouroboros-smoke-home"
SBOM_ROOT="$RUNNER_TEMP/ouroboros-sbom-payload"
mkdir -p "$MOUNT" "$HOME_DIR"
hdiutil attach "${{ steps.release_asset.outputs.path }}" -nobrowse -readonly -mountpoint "$MOUNT"
trap 'hdiutil detach "$MOUNT" >/dev/null' EXIT
test "$(uname -m)" = "arm64"
test -f "$MOUNT/Ouroboros.app/Contents/Resources/repo.bundle"
test -f "$MOUNT/Ouroboros.app/Contents/Resources/repo_bundle_manifest.json"
python scripts/fetch_claudexor_runtime.py --verify-only \
--output-dir "$MOUNT/Ouroboros.app/Contents/Resources/claudexor-runtime"
test -L "$MOUNT/Applications"
test "$(readlink "$MOUNT/Applications")" = "/Applications"
test -x "$MOUNT/Install CLI.command"
APP_EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$MOUNT/Ouroboros.app/Contents/Info.plist")"
lipo -archs "$MOUNT/Ouroboros.app/Contents/MacOS/$APP_EXECUTABLE" | tr ' ' '\n' | grep -qx arm64
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" \
"$MOUNT/Ouroboros.app/Contents/Resources/bin/ouroboros" --help >/dev/null
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" \
OUROBOROS_DATA_DIR="$RUNNER_TEMP/ouroboros-claudexor-smoke" \
OUROBOROS_BUNDLE_DIR="$MOUNT/Ouroboros.app/Contents/Resources" \
python scripts/claudexor_platform_smoke.py \
--managed-runtime --lane fixture --max-seconds 300
if [ "${{ needs.release-preflight.outputs.is_prerelease }}" != "true" ] \
&& [ "$HAS_APPLE_SIGNING" = "true" ]; then
codesign --verify --strict \
"$MOUNT/Ouroboros.app/Contents/Resources/betterleaks-standalone/bin/betterleaks"
codesign --verify --deep --strict "$MOUNT/Ouroboros.app"
fi
python scripts/betterleaks_platform_smoke.py \
--bundle-root "$MOUNT/Ouroboros.app/Contents/Resources"
rm -rf "$SBOM_ROOT"
mkdir -p "$SBOM_ROOT"
ditto "$MOUNT" "$SBOM_ROOT"
test -L "$SBOM_ROOT/Applications"
unlink "$SBOM_ROOT/Applications"
echo "sbom_path=$SBOM_ROOT" >> "$GITHUB_OUTPUT"
- name: Smoke final Linux archive
id: smoke_linux
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
SMOKE_ROOT="$RUNNER_TEMP/ouroboros-linux-${GITHUB_RUN_ATTEMPT}"
HOME_DIR="$RUNNER_TEMP/ouroboros-smoke-home"
mkdir -p "$SMOKE_ROOT" "$HOME_DIR"
tar -xzf "${{ steps.release_asset.outputs.path }}" -C "$SMOKE_ROOT"
# PyInstaller 6 onedir puts datas under _internal/ (the runtime resolves
# the bundle root itself — packaged_cli walks Resources/Frameworks/_internal).
test -f "$SMOKE_ROOT/Ouroboros/_internal/repo.bundle"
test -f "$SMOKE_ROOT/Ouroboros/_internal/repo_bundle_manifest.json"
python scripts/fetch_claudexor_runtime.py --verify-only \
--output-dir "$SMOKE_ROOT/Ouroboros/_internal/claudexor-runtime"
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" \
"$SMOKE_ROOT/Ouroboros/bin/ouroboros" --help >/dev/null
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" \
OUROBOROS_DATA_DIR="$RUNNER_TEMP/ouroboros-claudexor-smoke" \
OUROBOROS_BUNDLE_DIR="$SMOKE_ROOT/Ouroboros/_internal" \
python scripts/claudexor_platform_smoke.py \
--managed-runtime --lane fixture --max-seconds 300
python scripts/betterleaks_platform_smoke.py \
--bundle-root "$SMOKE_ROOT/Ouroboros/_internal"
echo "sbom_path=$SMOKE_ROOT" >> "$GITHUB_OUTPUT"
- name: Smoke final Linux AppImage
id: smoke_appimage
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
APPIMAGE="$PWD/dist/Ouroboros-${VERSION}-linux-x86_64.AppImage"
EXTRACT_ROOT="$RUNNER_TEMP/ouroboros-appimage-${GITHUB_RUN_ATTEMPT}"
HOME_DIR="$RUNNER_TEMP/ouroboros-appimage-home"
APP_ROOT="$HOME_DIR/Ouroboros"
DATA_DIR="$APP_ROOT/data"
PID_FILE="$APP_ROOT/ouroboros.pid"
PORT_FILE="$DATA_DIR/state/server_port"
CLI_LOG="$RUNNER_TEMP/ouroboros-appimage-cli.log"
LDD_LOG="$RUNNER_TEMP/ouroboros-appimage-ldd.log"
test -x "$APPIMAGE"
mkdir -p "$EXTRACT_ROOT" "$HOME_DIR" "$DATA_DIR"
(cd "$EXTRACT_ROOT" && "$APPIMAGE" --appimage-extract >/dev/null)
APPDIR="$EXTRACT_ROOT/squashfs-root"
test -x "$APPDIR/AppRun"
test -f "$APPDIR/ouroboros.desktop"
test -f "$APPDIR/ouroboros.png"
test -f "$APPDIR/usr/lib/ouroboros/_internal/repo.bundle"
test -f "$APPDIR/usr/lib/ouroboros/_internal/repo_bundle_manifest.json"
python scripts/fetch_claudexor_runtime.py --verify-only \
--output-dir "$APPDIR/usr/lib/ouroboros/_internal/claudexor-runtime"
VERSION_OUTPUT="$(APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE" --version)"
test "$VERSION_OUTPUT" = "Ouroboros $VERSION"
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" \
APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE" --cli --help >/dev/null
cleanup_appimage_smoke() {
if [ -s "$PID_FILE" ]; then
pid="$(tr -d '[:space:]' < "$PID_FILE")"
kill -TERM "$pid" 2>/dev/null || true
fi
}
trap cleanup_appimage_smoke EXIT
# `run --start` must launch a fresh outer AppImage runtime, not the
# raw PyInstaller binary inside the first temporary extraction. The
# deliberately empty prompt exits the CLI after readiness without
# enqueueing work; the desktop runtime must remain alive afterwards.
set +e
env -u DISPLAY -u WAYLAND_DISPLAY \
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" BROWSER=/bin/true \
OPENROUTER_API_KEY=ci-placeholder OUROBOROS_MAX_WORKERS=1 \
APPIMAGE_EXTRACT_AND_RUN=1 \
"$APPIMAGE" --cli run --start --detach >"$CLI_LOG" 2>&1
CLI_RC=$?
set -e
test "$CLI_RC" -eq 2
for _ in $(seq 1 180); do
if [ -s "$PID_FILE" ] && [ -s "$PORT_FILE" ]; then
PORT="$(tr -d '[:space:]' < "$PORT_FILE")"
if curl --fail --silent "http://127.0.0.1:$PORT/api/health" >/dev/null; then
break
fi
fi
sleep 0.5
done
test -s "$PID_FILE"
test -s "$PORT_FILE"
PORT="$(tr -d '[:space:]' < "$PORT_FILE")"
curl --fail --silent "http://127.0.0.1:$PORT/api/health" \
| jq -e --arg version "$VERSION" '.version == $version' >/dev/null
curl --fail --silent "http://127.0.0.1:$PORT/api/state" \
| jq -e '.supervisor_ready == true' >/dev/null
LAUNCHER_PID="$(tr -d '[:space:]' < "$PID_FILE")"
kill -0 "$LAUNCHER_PID"
LAUNCHER_EXE="$(readlink "/proc/$LAUNCHER_PID/exe")"
if [ ! -e "$LAUNCHER_EXE" ]; then
echo "AppImage launcher executable disappeared while the runtime is live: $LAUNCHER_EXE" >&2
cat "$CLI_LOG" >&2
exit 1
fi
ldd "$LAUNCHER_EXE" >"$LDD_LOG"
if grep -F "not found" "$LDD_LOG"; then
echo "AppImage launcher has unresolved shared libraries" >&2
exit 1
fi
APPIMAGE_RUNTIME_ROOT="${LAUNCHER_EXE%/usr/lib/ouroboros/Ouroboros}"
APPIMAGE_PRIVATE_BASE="${APPIMAGE_RUNTIME_ROOT%/*}"
APPIMAGE_CUSTODIAN_PID="$(ps -o ppid= -p "$LAUNCHER_PID" | tr -d '[:space:]')"
if [ -z "$APPIMAGE_CUSTODIAN_PID" ] || ! kill -0 "$APPIMAGE_CUSTODIAN_PID" 2>/dev/null; then
echo "Could not identify the AppRun custodian for launcher $LAUNCHER_PID" >&2
exit 1
fi
APPIMAGE_RUNTIME_PID="$(ps -o ppid= -p "$APPIMAGE_CUSTODIAN_PID" | tr -d '[:space:]')"
if [ -z "$APPIMAGE_RUNTIME_PID" ] || ! kill -0 "$APPIMAGE_RUNTIME_PID" 2>/dev/null; then
echo "Could not identify the AppImage runtime that owns custodian $APPIMAGE_CUSTODIAN_PID" >&2
exit 1
fi
APPIMAGE_RUNTIME_EXE="$(readlink "/proc/$APPIMAGE_RUNTIME_PID/exe")"
if [ "$APPIMAGE_RUNTIME_EXE" != "$APPIMAGE" ]; then
echo "Custodian parent is not the stable AppImage runtime: $APPIMAGE_RUNTIME_EXE" >&2
exit 1
fi
kill -TERM "$LAUNCHER_PID"
for _ in $(seq 1 120); do
if ! kill -0 "$LAUNCHER_PID" 2>/dev/null; then
break
fi
sleep 0.5
done
if kill -0 "$LAUNCHER_PID" 2>/dev/null; then
echo "AppImage launcher did not shut down after SIGTERM" >&2
exit 1
fi
# The marker-gated AppRun custodian waits for the launcher, removes
# the private extraction, and exits. The type-2 runtime reaps that
# custodian before it terminates, so runtime death orders the cleanup
# proof without racing a fixed pathname interval.
for _ in $(seq 1 240); do
! kill -0 "$APPIMAGE_RUNTIME_PID" 2>/dev/null && break
sleep 0.5
done
if kill -0 "$APPIMAGE_RUNTIME_PID" 2>/dev/null; then
echo "AppImage runtime did not finish cleanup after launcher shutdown" >&2
exit 1
fi
if kill -0 "$APPIMAGE_CUSTODIAN_PID" 2>/dev/null; then
echo "AppRun custodian remained alive after its AppImage runtime exited" >&2
exit 1
fi
if [ -e "$APPIMAGE_RUNTIME_ROOT" ]; then
echo "AppRun custodian left its extraction behind: $APPIMAGE_RUNTIME_ROOT" >&2
cat "$CLI_LOG" >&2
exit 1
fi
if [ -e "$APPIMAGE_PRIVATE_BASE" ]; then
echo "AppRun custodian left its private runtime root behind: $APPIMAGE_PRIVATE_BASE" >&2
cat "$CLI_LOG" >&2
exit 1
fi
if [ -e "$PID_FILE" ]; then
echo "AppImage launcher left its PID file behind: $PID_FILE" >&2
exit 1
fi
trap - EXIT
HOME="$HOME_DIR" XDG_CACHE_HOME="$HOME_DIR/.cache" \
OUROBOROS_DATA_DIR="$RUNNER_TEMP/ouroboros-appimage-claudexor-smoke" \
OUROBOROS_BUNDLE_DIR="$APPDIR/usr/lib/ouroboros/_internal" \
python scripts/claudexor_platform_smoke.py \
--managed-runtime --lane fixture --max-seconds 300
python scripts/betterleaks_platform_smoke.py \
--bundle-root "$APPDIR/usr/lib/ouroboros/_internal"
echo "artifact_path=$APPIMAGE" >> "$GITHUB_OUTPUT"
echo "sbom_path=$APPDIR" >> "$GITHUB_OUTPUT"
- name: Smoke final Windows archive
id: smoke_windows
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$SmokeRoot = Join-Path $env:RUNNER_TEMP "ouroboros-windows-$env:GITHUB_RUN_ATTEMPT"
$HomeDir = Join-Path $env:RUNNER_TEMP "ouroboros-smoke-home"
New-Item -ItemType Directory -Force -Path $SmokeRoot, $HomeDir | Out-Null
Expand-Archive -Path "${{ steps.release_asset.outputs.path }}" -DestinationPath $SmokeRoot
# PyInstaller 6 onedir puts datas under _internal\ (the runtime resolves
# the bundle root itself — packaged_cli walks Resources/Frameworks/_internal).
if (-not (Test-Path "$SmokeRoot\Ouroboros\_internal\repo.bundle")) { throw "repo.bundle missing" }
if (-not (Test-Path "$SmokeRoot\Ouroboros\_internal\repo_bundle_manifest.json")) { throw "repo bundle manifest missing" }
python scripts/fetch_claudexor_runtime.py --verify-only --output-dir "$SmokeRoot\Ouroboros\_internal\claudexor-runtime"
if ($LASTEXITCODE -ne 0) { throw "embedded Claudexor runtime verification failed: $LASTEXITCODE" }
$env:HOME = $HomeDir
$env:USERPROFILE = $HomeDir
$env:LOCALAPPDATA = Join-Path $HomeDir "AppData\Local"
$env:APPDATA = Join-Path $HomeDir "AppData\Roaming"
$env:HOMEDRIVE = Split-Path -Qualifier $HomeDir
$env:HOMEPATH = $HomeDir.Substring($env:HOMEDRIVE.Length)
New-Item -ItemType Directory -Force -Path $env:LOCALAPPDATA, $env:APPDATA | Out-Null
& "$SmokeRoot\Ouroboros\bin\ouroboros.cmd" --help | Out-Null
if ($LASTEXITCODE -ne 0) { throw "packaged CLI smoke failed: $LASTEXITCODE" }
$env:OUROBOROS_DATA_DIR = Join-Path $env:RUNNER_TEMP "ouroboros-claudexor-smoke"
$env:OUROBOROS_BUNDLE_DIR = "$SmokeRoot\Ouroboros\_internal"
python scripts/claudexor_platform_smoke.py --managed-runtime --lane fixture --max-seconds 300
if ($LASTEXITCODE -ne 0) { throw "packaged Claudexor runtime smoke failed: $LASTEXITCODE" }
python scripts/betterleaks_platform_smoke.py --bundle-root "$SmokeRoot\Ouroboros\_internal"
if ($LASTEXITCODE -ne 0) { throw "packaged Betterleaks runtime smoke failed: $LASTEXITCODE" }
Add-Content -Path $env:GITHUB_OUTPUT -Value "sbom_path=$SmokeRoot"
- name: Record packaged artifact smoke
shell: bash
run: |
EXTRA_CHECKS=()
if [ "${{ matrix.proof_id }}" = "macos-arm64" ]; then
EXTRA_CHECKS+=(--check applications_shortcut --check install_cli_command --check arm64_main_executable)
fi
python scripts/release_proof.py record-smoke \
--proof-id "${{ matrix.proof_id }}" \
--artifact "${{ steps.release_asset.outputs.path }}" \
--output "dist/release-smoke-${{ matrix.proof_id }}.json" \
--commit "$GITHUB_SHA" \
--tag "$GITHUB_REF_NAME" \
--check embedded_repo_bundle \
--check embedded_claudexor_runtime \
--check embedded_betterleaks_runtime \
--check packaged_cli_help \
"${EXTRA_CHECKS[@]}"
- name: Record AppImage smoke
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
python scripts/release_proof.py record-smoke \
--proof-id linux-appimage-x86_64 \
--artifact "${{ steps.smoke_appimage.outputs.artifact_path }}" \
--output dist/release-smoke-linux-appimage-x86_64.json \
--commit "$GITHUB_SHA" --tag "$GITHUB_REF_NAME" \
--check embedded_repo_bundle --check embedded_claudexor_runtime \
--check embedded_betterleaks_runtime \
--check packaged_cli_help --check appimage_extract_and_run \
--check appimage_metadata --check product_version \
--check browser_fallback_start --check gateway_readiness \
--check clean_shutdown --check shared_libraries
- name: Install digest-pinned Syft
id: syft
shell: bash
env:
SYFT_VERSION: 1.50.0
SYFT_ARCHIVE: ${{ matrix.syft_archive }}
SYFT_SHA256: ${{ matrix.syft_sha256 }}
SYFT_BINARY: ${{ matrix.syft_binary }}
run: |
set -euo pipefail
ARCHIVE="$RUNNER_TEMP/$SYFT_ARCHIVE"
TOOL_DIR="$RUNNER_TEMP/syft-$SYFT_VERSION"
curl --fail --location --silent --show-error \
"https://github.com/anchore/syft/releases/download/v$SYFT_VERSION/$SYFT_ARCHIVE" \
--output "$ARCHIVE"
python -c 'import hashlib,sys; p,e=sys.argv[1:]; a=hashlib.sha256(open(p,"rb").read()).hexdigest(); raise SystemExit(0 if a == e else f"Syft digest mismatch: {a} != {e}")' \
"$ARCHIVE" "$SYFT_SHA256"
mkdir -p "$TOOL_DIR"
python -c 'import shutil,sys; shutil.unpack_archive(sys.argv[1], sys.argv[2])' \
"$ARCHIVE" "$TOOL_DIR"
test -f "$TOOL_DIR/$SYFT_BINARY"
echo "path=$TOOL_DIR/$SYFT_BINARY" >> "$GITHUB_OUTPUT"
- name: Generate CycloneDX SBOM from packaged payload
shell: bash
env:
SBOM_PATH: ${{ steps.smoke_macos.outputs.sbom_path || steps.smoke_linux.outputs.sbom_path || steps.smoke_windows.outputs.sbom_path }}
run: |
set -euo pipefail
"${{ steps.syft.outputs.path }}" "dir:$SBOM_PATH" \
--output "cyclonedx-json=dist/sbom-${{ matrix.proof_id }}.cdx.json"
- name: Generate AppImage CycloneDX SBOM
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
"${{ steps.syft.outputs.path }}" "dir:${{ steps.smoke_appimage.outputs.sbom_path }}" \
--output cyclonedx-json=dist/sbom-linux-appimage-x86_64.cdx.json
- name: Attest build provenance
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
with:
subject-path: ${{ steps.release_asset.outputs.path }}
- name: Attest SBOM
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
with:
subject-path: ${{ steps.release_asset.outputs.path }}
sbom-path: dist/sbom-${{ matrix.proof_id }}.cdx.json
- name: Attest AppImage build provenance
if: matrix.os == 'ubuntu-latest'
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
with:
subject-path: ${{ steps.smoke_appimage.outputs.artifact_path }}
- name: Attest AppImage SBOM
if: matrix.os == 'ubuntu-latest'
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
with:
subject-path: ${{ steps.smoke_appimage.outputs.artifact_path }}
sbom-path: dist/sbom-linux-appimage-x86_64.cdx.json
# —— Linux native packages ——
# The .deb and .rpm wrap the same dist/Ouroboros payload the tarball
# smoke above already proved, so they reuse its SBOM: the bytes under
# /opt/ouroboros are identical to the ones inside the archive. Each
# package still earns its own smoke receipt, from a real install in a
# stock Ubuntu and Fedora container.
- name: Build Linux .deb and .rpm packages
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq rpm
bash scripts/build_linux_packages.sh
# Release-gating lane only: Docker Hub images. The Astra Linux and RED OS
# runs live in the informational vendor-package-smoke job, so a vendor
# registry outage cannot hold back a tagged release.
- name: Smoke Linux packages in Ubuntu and Fedora containers
id: linux_packages
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
DEB="dist/ouroboros_${VERSION}_amd64.deb"
RPM="dist/ouroboros-${VERSION}-1.x86_64.rpm"
RPM_RED80="dist/ouroboros-${VERSION}-1.red80.x86_64.rpm"
bash scripts/smoke_linux_packages.sh official "$DEB" "$RPM" "$RPM_RED80"
echo "deb=$DEB" >> "$GITHUB_OUTPUT"
echo "rpm=$RPM" >> "$GITHUB_OUTPUT"
echo "rpm_red80=$RPM_RED80" >> "$GITHUB_OUTPUT"
- name: Record Linux package smoke and reuse payload SBOM
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
set -euo pipefail
PAYLOAD_SBOM="dist/sbom-linux-x86_64.cdx.json"
test -f "$PAYLOAD_SBOM"
record() {
python scripts/release_proof.py record-smoke \
--proof-id "$1" \
--artifact "$2" \
--output "dist/release-smoke-$1.json" \
--commit "$GITHUB_SHA" \
--tag "$GITHUB_REF_NAME" \
--check package_install \
--check runtime_dependency \
--check embedded_betterleaks_runtime \
--check packaged_cli_help \
--check desktop_entry \
--check systemd_user_unit \
--check desktop_launcher_start
cp "$PAYLOAD_SBOM" "dist/sbom-$1.cdx.json"
}
record linux-deb-amd64 "${{ steps.linux_packages.outputs.deb }}"
record linux-rpm-x86_64 "${{ steps.linux_packages.outputs.rpm }}"
record linux-rpm-red80-x86_64 "${{ steps.linux_packages.outputs.rpm_red80 }}"
- name: Attest Linux package provenance
if: matrix.os == 'ubuntu-latest'
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
with:
subject-path: |
${{ steps.linux_packages.outputs.deb }}
${{ steps.linux_packages.outputs.rpm }}
${{ steps.linux_packages.outputs.rpm_red80 }}
- name: Attest .deb SBOM
if: matrix.os == 'ubuntu-latest'
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
with:
subject-path: ${{ steps.linux_packages.outputs.deb }}
sbom-path: dist/sbom-linux-deb-amd64.cdx.json
- name: Attest .rpm SBOM
if: matrix.os == 'ubuntu-latest'
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
with:
subject-path: ${{ steps.linux_packages.outputs.rpm }}
sbom-path: dist/sbom-linux-rpm-x86_64.cdx.json
- name: Attest RED OS .rpm SBOM
if: matrix.os == 'ubuntu-latest'
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1
with:
subject-path: ${{ steps.linux_packages.outputs.rpm_red80 }}
sbom-path: dist/sbom-linux-rpm-red80-x86_64.cdx.json
# Upload the archive and its exact proof companions. The .deb/.rpm lines
# match nothing on macOS and Windows, which is fine — `error` fires only
# when the whole path set is empty.
- name: Upload build artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: ouroboros-${{ matrix.os }}
path: |
${{ steps.release_asset.outputs.path }}
dist/release-smoke-${{ matrix.proof_id }}.json
dist/sbom-${{ matrix.proof_id }}.cdx.json
dist/*.AppImage
dist/release-smoke-linux-appimage-x86_64.json
dist/sbom-linux-appimage-x86_64.cdx.json
dist/ouroboros_*_amd64.deb
dist/ouroboros-*-1.x86_64.rpm
dist/ouroboros-*-1.red80.x86_64.rpm
dist/release-smoke-linux-deb-amd64.json
dist/release-smoke-linux-rpm-x86_64.json
dist/release-smoke-linux-rpm-red80-x86_64.json
dist/sbom-linux-deb-amd64.cdx.json
dist/sbom-linux-rpm-x86_64.cdx.json
dist/sbom-linux-rpm-red80-x86_64.cdx.json
if-no-files-found: error
retention-days: 30
# ──────────────────────────────────────────────────────────────────
# Vendor distro smoke: informational, never blocks a release.
#
# Astra Linux and RED OS install the same .deb/.rpm the release-gating lane
# already proved on Ubuntu and Fedora. What this adds is confirmation on the
# vendor OS itself — worth having, but it reaches two third-party registries
# whose availability from GitHub runners is outside this project's control.
# Gating a tagged release on that would trade a real publication failure for
# someone else's outage, so the job runs alongside `release` rather than
# ahead of it, and `continue-on-error` keeps a red result informational.
# ──────────────────────────────────────────────────────────────────
vendor-package-smoke:
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
continue-on-error: true
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Download Linux build artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ouroboros-ubuntu-latest
path: linux-artifacts/
- name: Smoke packages on Astra Linux and RED OS
id: vendor_smoke
shell: bash
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
bash scripts/smoke_linux_packages.sh vendor \
"linux-artifacts/ouroboros_${VERSION}_amd64.deb" \
"linux-artifacts/ouroboros-${VERSION}-1.x86_64.rpm" \
"linux-artifacts/ouroboros-${VERSION}-1.red80.x86_64.rpm"
# Without this, a non-blocking failure is easy to miss in the run list.
- name: Report vendor smoke outcome
if: always()
shell: bash
run: |
if [ "${{ steps.vendor_smoke.outcome }}" = "success" ]; then
echo "Vendor package smoke passed on Astra Linux 1.8 and RED OS 8." >> "$GITHUB_STEP_SUMMARY"
else
{
echo "Vendor package smoke did not pass on Astra Linux 1.8 / RED OS 8."
echo ""
echo "This lane is informational and does not block the release."
echo "Check whether registry.astralinux.ru and registry.red-soft.ru were"
echo "reachable before treating this as a packaging defect."
} >> "$GITHUB_STEP_SUMMARY"
fi
# ──────────────────────────────────────────────────────────────────
# 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, skill-smoke]
runs-on: ubuntu-latest
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
attestations: read
artifact-metadata: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.10'
- name: Download all artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: release-artifacts/
merge-multiple: true
- name: Assemble release proof capsule and notes
shell: bash
run: |
set -euo pipefail
PREVIOUS_TAG="$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)"
ARGS=()
if [ -n "$PREVIOUS_TAG" ]; then ARGS+=(--previous-tag "$PREVIOUS_TAG"); fi
python scripts/release_proof.py assemble \
--directory release-artifacts \
--repository "$GITHUB_REPOSITORY" \
--tag "$GITHUB_REF_NAME" \
--commit "$GITHUB_SHA" \
--run-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
--notes-output release-notes.md \
"${ARGS[@]}"
- name: Verify artifact attestations
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
for file in release-artifacts/Ouroboros-*.dmg \
release-artifacts/Ouroboros-*-linux-x86_64.tar.gz \
release-artifacts/Ouroboros-*-linux-x86_64.AppImage \
release-artifacts/ouroboros_*_amd64.deb \
release-artifacts/ouroboros-*-1.x86_64.rpm \
release-artifacts/ouroboros-*-1.red80.x86_64.rpm \
release-artifacts/Ouroboros-*-windows-x64.zip; do
SOURCE_ARGS=(
--repo "$GITHUB_REPOSITORY"
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/ci.yml"
--source-digest "$GITHUB_SHA"
--source-ref "$GITHUB_REF"
)
gh attestation verify "$file" "${SOURCE_ARGS[@]}"
gh attestation verify "$file" "${SOURCE_ARGS[@]}" \
--predicate-type https://cyclonedx.org/bom
done
- name: Require an unpublished release slot
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
BODY="$RUNNER_TEMP/existing-release.json"
STATUS="$(curl --silent --show-error --output "$BODY" --write-out '%{http_code}' \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME")"
case "$STATUS" in
404)
echo "No release exists for $GITHUB_REF_NAME; a draft may be created."
;;
200)
if ! jq -e '.draft == true' "$BODY" >/dev/null; then
echo "Refusing to modify the published release for $GITHUB_REF_NAME." >&2
exit 1
fi
echo "The existing release is still a draft and may be repaired."
;;
*)
echo "GitHub release lookup failed with HTTP $STATUS." >&2
exit 1
;;
esac
- name: Verify remote release tag before draft
shell: bash
run: |
set -euo pipefail
TAG_REF="refs/tags/$GITHUB_REF_NAME"
PEELED_REF="${TAG_REF}^{}"
REMOTE_ROWS="$(git ls-remote --exit-code origin "$TAG_REF" "$PEELED_REF")"
TAG_OBJECT_SHA="$(printf '%s\n' "$REMOTE_ROWS" | awk -v ref="$TAG_REF" '$2 == ref {print $1}')"
PEELED_SHA="$(printf '%s\n' "$REMOTE_ROWS" | awk -v ref="$PEELED_REF" '$2 == ref {print $1}')"
if [ -z "$TAG_OBJECT_SHA" ] || [ -z "$PEELED_SHA" ]; then
echo "Remote tag $GITHUB_REF_NAME is missing or is not annotated." >&2
exit 1
fi
git fetch origin "$TAG_REF:$TAG_REF" --force
test "$(git cat-file -t "$TAG_REF")" = "tag"
LOCAL_PEELED="$(git rev-parse "$PEELED_REF")"
if [ "$LOCAL_PEELED" != "$GITHUB_SHA" ] || [ "$PEELED_SHA" != "$GITHUB_SHA" ]; then
echo "Remote tag $GITHUB_REF_NAME no longer targets $GITHUB_SHA." >&2
exit 1
fi
- name: Create draft GitHub Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
files: |
release-artifacts/Ouroboros-*.dmg
release-artifacts/Ouroboros-*-linux-x86_64.tar.gz
release-artifacts/Ouroboros-*-linux-x86_64.AppImage
release-artifacts/ouroboros_*_amd64.deb
release-artifacts/ouroboros-*-1.x86_64.rpm
release-artifacts/ouroboros-*-1.red80.x86_64.rpm
release-artifacts/Ouroboros-*-windows-x64.zip
release-artifacts/release-smoke-macos-arm64.json
release-artifacts/release-smoke-linux-x86_64.json
release-artifacts/release-smoke-linux-appimage-x86_64.json
release-artifacts/release-smoke-linux-deb-amd64.json
release-artifacts/release-smoke-linux-rpm-x86_64.json
release-artifacts/release-smoke-linux-rpm-red80-x86_64.json
release-artifacts/release-smoke-windows-x64.json
release-artifacts/sbom-macos-arm64.cdx.json
release-artifacts/sbom-linux-x86_64.cdx.json
release-artifacts/sbom-linux-appimage-x86_64.cdx.json
release-artifacts/sbom-linux-deb-amd64.cdx.json
release-artifacts/sbom-linux-rpm-x86_64.cdx.json
release-artifacts/sbom-linux-rpm-red80-x86_64.cdx.json
release-artifacts/sbom-windows-x64.cdx.json
release-artifacts/SHA256SUMS
release-artifacts/release-evidence.json
body_path: release-notes.md
fail_on_unmatched_files: true
# The existing annotated tag is exact-SHA verified above. Passing an
# explicit target would make GitHub require Workflows:write, which
# the Actions GITHUB_TOKEN cannot receive.
draft: true
prerelease: ${{ needs.release-preflight.outputs.is_prerelease == 'true' }}
- name: Verify uploaded draft
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
METADATA="$RUNNER_TEMP/uploaded-release-assets.json"
gh release view "$GITHUB_REF_NAME" --json assets \
--jq '{assets:[.assets[]|{name,size,digest}]}' > "$METADATA"
python scripts/release_proof.py verify-uploaded \
--directory release-artifacts \
--metadata "$METADATA"
- name: Verify remote release tag before publish
shell: bash
run: |
set -euo pipefail
TAG_REF="refs/tags/$GITHUB_REF_NAME"
PEELED_REF="${TAG_REF}^{}"
REMOTE_ROWS="$(git ls-remote --exit-code origin "$TAG_REF" "$PEELED_REF")"
TAG_OBJECT_SHA="$(printf '%s\n' "$REMOTE_ROWS" | awk -v ref="$TAG_REF" '$2 == ref {print $1}')"
PEELED_SHA="$(printf '%s\n' "$REMOTE_ROWS" | awk -v ref="$PEELED_REF" '$2 == ref {print $1}')"
if [ -z "$TAG_OBJECT_SHA" ] || [ -z "$PEELED_SHA" ]; then
echo "Remote tag $GITHUB_REF_NAME is missing or is not annotated." >&2
exit 1
fi
git fetch origin "$TAG_REF:$TAG_REF" --force
test "$(git cat-file -t "$TAG_REF")" = "tag"
LOCAL_PEELED="$(git rev-parse "$PEELED_REF")"
if [ "$LOCAL_PEELED" != "$GITHUB_SHA" ] || [ "$PEELED_SHA" != "$GITHUB_SHA" ]; then
echo "Remote tag $GITHUB_REF_NAME no longer targets $GITHUB_SHA." >&2
exit 1
fi
- name: Publish verified GitHub Release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
if [ "${{ needs.release-preflight.outputs.is_prerelease }}" = "true" ]; then
gh release edit "$GITHUB_REF_NAME" --draft=false --prerelease
else
gh release edit "$GITHUB_REF_NAME" --draft=false --latest
fi