mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Remove completed campaign records, one-time adoption/transplant machinery and incidental line floors. Keep current contracts and generated inventories with their readers, and direct optional domain reports to stdout or an explicit file. Document continuing-purpose review in the existing handbook and checklists. No version bump; ordinary release gates and runtime behavior are preserved. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
1768 lines
94 KiB
YAML
1768 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'
|
|
- name: Set up Windows POSIX test utilities
|
|
if: runner.os == 'Windows'
|
|
id: posix-tools
|
|
uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
|
|
with:
|
|
msystem: MSYS
|
|
release: false
|
|
update: true
|
|
install: rsync
|
|
- name: Select Windows POSIX test utilities
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
run: '"${{ steps.posix-tools.outputs.msys2-location }}\usr\bin" >> $env:GITHUB_PATH'
|
|
# 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: 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
|