mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-15 13:43:13 +00:00
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
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>
1297 lines
64 KiB
YAML
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
|