ouroboros/.github/workflows/ci.yml
Ouroboros dcf48aacce
Some checks failed
CI / quick-test (push) Has been cancelled
CI / full-test (macos-latest) (push) Has been cancelled
CI / full-test (ubuntu-latest) (push) Has been cancelled
CI / full-test (windows-latest) (push) Has been cancelled
CI / integration-test (push) Has been cancelled
CI / skill-smoke (macos-latest) (push) Has been cancelled
CI / skill-smoke (ubuntu-latest) (push) Has been cancelled
CI / skill-smoke (windows-latest) (push) Has been cancelled
CI / marker-guards (push) Has been cancelled
CI / ui-smoke (push) Has been cancelled
CI / docker-ui-smoke (push) Has been cancelled
CI / docker-portable-test (push) Has been cancelled
Scorecard analysis workflow / Scorecard analysis (push) Has been cancelled
Sync Joi Lab Fork Mirror / sync (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / fixture · macos-latest · exact managed runtime, fake harness, no model (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / fixture · ubuntu-latest · exact managed runtime, fake harness, no model (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / fixture · windows-latest · exact managed runtime, fake harness, no model (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / live · macos-latest · claude · API key only, subscription NOT covered (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / live · ubuntu-latest · claude · API key only, subscription NOT covered (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / live · windows-latest · claude · API key only, subscription NOT covered (push) Has been cancelled
Claudexor platform gate (API keys — subscription auth NOT covered) / live · macos-latest · codex · API key only, subscription NOT covered (push) Has been cancelled
CI / release-preflight (push) Has been cancelled
CI / build (dmg, macos-latest, macos-arm64, syft_1.50.0_darwin_arm64.tar.gz, syft, e32fdb9d47823fa633748a1efca2528fd77c37469ea93c9e40ab835da44e4cce) (push) Has been cancelled
CI / build (tar.gz, ubuntu-latest, linux-x86_64, syft_1.50.0_linux_amd64.tar.gz, syft, bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788) (push) Has been cancelled
CI / vendor-package-smoke (push) Has been cancelled
CI / release (push) Has been cancelled
CI / build (zip, windows-latest, windows-x64, syft_1.50.0_windows_amd64.zip, syft.exe, 815ee6973ec5dff6a671d7f41b0e78835a8c45b91d5a39f4743ea1cee833d3be) (push) Has been cancelled
release 6.97.2: repair packaged lifecycle portability
Own nested AppImage extraction cleanup in the marker-gated AppRun custodian, preserve path-resolution failure semantics across supported Python versions, and advance all synchronized release carriers for the fix-forward release after v6.97.1.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-08-11 21:14:51 +03:00

1297 lines
64 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): Push to ouroboros-stable / manual / tag → Full 3-OS matrix (~5 min)
# Tier 2.5 (Integration): Push to main / ouroboros / ouroboros-stable / manual / tag → Real-provider tests (~2 min)
# Tier 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)
#
# Tier 2.5 requires OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY /
# CLOUDRU_FOUNDATION_MODELS_API_KEY in
# repository secrets and runs the `integration` pytest marker; locally these
# tests are excluded by `addopts = -m 'not integration'` in pyproject.toml.
name: CI
# Two separate push triggers: branches have path filters, tags do not.
# This ensures tag pushes always fire (even if only VERSION/README changed).
on:
push:
branches: [main, ouroboros, ouroboros-stable]
paths:
- 'ouroboros/**'
- 'supervisor/**'
- 'server.py'
- 'tests/**'
- 'web/**'
- '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 the deterministic quick-test job matches pull_request refs below.
pull_request:
branches: [ouroboros]
workflow_dispatch:
# 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')
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"
steps:
- uses: actions/checkout@v4
- 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
# 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" -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" -q --tb=short
- name: Guard extracted transport imports stay out of core
run: python -m pytest tests/test_no_core_a2a_telegram_imports.py -q
# ──────────────────────────────────────────────────────────────────
# Tier 2: Full matrix (stable branch, manual, or tag push)
# ──────────────────────────────────────────────────────────────────
full-test:
if: |
github.ref == 'refs/heads/ouroboros-stable'
|| github.event_name == 'workflow_dispatch'
|| startsWith(github.ref, 'refs/tags/v')
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
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"
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-env
# 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" -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" -q --tb=short
- name: Guard extracted transport imports stay out of core
run: python -m pytest tests/test_no_core_a2a_telegram_imports.py -q
# ──────────────────────────────────────────────────────────────────
# Tier 2.5: Integration tests against real provider APIs
# Triggered on push to main / ouroboros / ouroboros-stable, manual,
# or tag v*. Requires OPENROUTER_API_KEY / OPENAI_API_KEY /
# ANTHROPIC_API_KEY / CLOUDRU_FOUNDATION_MODELS_API_KEY in repository secrets. The `integration` pytest
# marker (in pyproject.toml) controls inclusion via `-m integration`;
# within an included test file, missing-key skipping is done by per-
# test `@pytest.mark.skipif(not os.environ.get(KEY))` decorators (see
# tests/test_provider_integration.py). NOT a `needs:` of build/
# release: a provider outage must not block a tagged release.
# ──────────────────────────────────────────────────────────────────
integration-test:
if: |
github.event_name == 'workflow_dispatch'
|| github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/ouroboros'
|| github.ref == 'refs/heads/ouroboros-stable'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.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 }}
CLOUDRU_FOUNDATION_MODELS_API_KEY: ${{ secrets.CLOUDRU_FOUNDATION_MODELS_API_KEY }}
CLOUDRU_FOUNDATION_MODELS_BASE_URL: ${{ secrets.CLOUDRU_FOUNDATION_MODELS_BASE_URL }}
run: python -m pytest tests/test_provider_integration.py -m integration -q --tb=short
# ──────────────────────────────────────────────────────────────────
# 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 — the opposite trade-off from integration-test, which is
# deliberately NOT a release need. 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
ui-smoke:
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: Install UI smoke browser binaries
run: python -m playwright install --with-deps chromium webkit
- name: Run host UI smoke
env:
OUROBOROS_RUN_UI_SMOKE: "1"
run: python -m pytest tests/ -m ui_browser -q --tb=short
- name: Run browser tools Chromium/WebKit smoke
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"
# ──────────────────────────────────────────────────────────────────
# Tier 3: Build & Release (tag push only)
# ──────────────────────────────────────────────────────────────────
release-preflight:
if: startsWith(github.ref, 'refs/tags/v')
needs: full-test
runs-on: ubuntu-latest
outputs:
is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Validate tag matches VERSION
id: release_meta
run: |
python - <<'PY'
import os
import pathlib
import re
from ouroboros.tools.release_sync import is_release_version
version = pathlib.Path("VERSION").read_text(encoding="utf-8").strip()
tag = os.environ["GITHUB_REF_NAME"].strip()
expected_tag = f"v{version}"
if tag != expected_tag:
raise SystemExit(f"Release tag mismatch: {tag} != {expected_tag}")
if not is_release_version(version):
raise SystemExit(f"VERSION is not a supported release version: {version!r}")
is_prerelease = bool(re.search(r'(?:rc|alpha|beta|a|b)\.?\d+$', version, re.IGNORECASE))
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"is_prerelease={'true' if is_prerelease else 'false'}\n")
PY
build:
if: startsWith(github.ref, 'refs/tags/v')
needs: [full-test, release-preflight]
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_MANAGED_SOURCE_BRANCH: ouroboros
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
# 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 --deep --strict "$MOUNT/Ouroboros.app"
fi
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
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
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" }
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 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 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 packaged_cli_help \
--check desktop_entry \
--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
target_commitish: ${{ github.sha }}
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