chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,876 @@
|
||||
name: CI
|
||||
|
||||
"on":
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
OPENSQUILLA_TESTING: "true"
|
||||
|
||||
jobs:
|
||||
classify-changes:
|
||||
name: Classify changed files
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
docs_only: ${{ steps.classify.outputs.docs_only }}
|
||||
runtime_changed: ${{ steps.classify.outputs.runtime_changed }}
|
||||
test_changed: ${{ steps.classify.outputs.test_changed }}
|
||||
ci_changed: ${{ steps.classify.outputs.ci_changed }}
|
||||
dependency_changed: ${{ steps.classify.outputs.dependency_changed }}
|
||||
release_changed: ${{ steps.classify.outputs.release_changed }}
|
||||
windows_full_required: ${{ steps.classify.outputs.windows_full_required }}
|
||||
frontend_changed: ${{ steps.classify.outputs.frontend_changed }}
|
||||
tui_changed: ${{ steps.classify.outputs.tui_changed }}
|
||||
desktop_changed: ${{ steps.classify.outputs.desktop_changed }}
|
||||
python_changed: ${{ steps.classify.outputs.python_changed }}
|
||||
platform_sensitive_changed: ${{ steps.classify.outputs.platform_sensitive_changed }}
|
||||
build_wheel_required: ${{ steps.classify.outputs.build_wheel_required }}
|
||||
full_required: ${{ steps.classify.outputs.full_required }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: List changed files
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
changed_files="${RUNNER_TEMP}/changed-files.txt"
|
||||
|
||||
case "${{ github.event_name }}" in
|
||||
pull_request)
|
||||
git diff --name-only \
|
||||
"${{ github.event.pull_request.base.sha }}" \
|
||||
"${{ github.event.pull_request.head.sha }}" > "${changed_files}"
|
||||
;;
|
||||
push)
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.event.after }}"
|
||||
if [[ -n "${before}" && "${before}" != "0000000000000000000000000000000000000000" ]]; then
|
||||
git diff --name-only "${before}" "${after}" > "${changed_files}"
|
||||
else
|
||||
printf '.ci/run-all\n' > "${changed_files}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf '.ci/run-all\n' > "${changed_files}"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Changed files:"
|
||||
sed 's/^/ /' "${changed_files}"
|
||||
printf 'CHANGED_FILES=%s\n' "${changed_files}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Classify changed files
|
||||
id: classify
|
||||
shell: bash
|
||||
run: bash .github/scripts/classify-ci-changes.sh "${CHANGED_FILES}"
|
||||
|
||||
workflow-lint:
|
||||
name: Validate GitHub Actions workflows
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run actionlint
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -color=false
|
||||
|
||||
frontend-check:
|
||||
name: Frontend build and typecheck
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.frontend_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: opensquilla-webui/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: opensquilla-webui
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend unit tests
|
||||
working-directory: opensquilla-webui
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: opensquilla-webui
|
||||
run: npm run build
|
||||
|
||||
- name: Verify committed dist is fresh
|
||||
run: |
|
||||
if [ ! -f "src/opensquilla/gateway/static/dist/index.html" ]; then
|
||||
echo "ERROR: Frontend dist not found"
|
||||
exit 1
|
||||
fi
|
||||
# The build above regenerates src/opensquilla/gateway/static/dist. If
|
||||
# the committed dist differs from a fresh build, a Web UI source change
|
||||
# was made without rebuilding + committing the bundle it ships (the
|
||||
# gateway serves the committed dist, not source). Fail so a stale
|
||||
# bundle can never ship (e.g. an approval-button removal that only
|
||||
# lands in source — the #413 residue).
|
||||
if ! git diff --quiet -- src/opensquilla/gateway/static/dist; then
|
||||
echo "ERROR: committed Web UI dist is stale — rebuild and commit it:"
|
||||
echo " cd opensquilla-webui && npm ci && npm run build"
|
||||
echo "Changed dist files:"
|
||||
git --no-pager diff --stat -- src/opensquilla/gateway/static/dist
|
||||
exit 1
|
||||
fi
|
||||
echo "Frontend dist verified fresh"
|
||||
|
||||
tui-check:
|
||||
name: OpenTUI package tests
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.tui_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install OpenTUI host dependencies
|
||||
working-directory: src/opensquilla/cli/tui/opentui/package
|
||||
shell: bash
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run OpenTUI Node tests
|
||||
working-directory: src/opensquilla/cli/tui/opentui/package
|
||||
run: npm run test
|
||||
|
||||
- name: Run OpenTUI Bun tests
|
||||
working-directory: src/opensquilla/cli/tui/opentui/package
|
||||
run: bun run test:bun
|
||||
|
||||
desktop-check:
|
||||
name: Desktop Electron unit tests
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# The pure-logic unit tests run against the compiled dist and never launch
|
||||
# Electron, so skip the heavy Electron/Playwright binary downloads.
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: desktop/electron/package-lock.json
|
||||
|
||||
- name: Install desktop dependencies
|
||||
working-directory: desktop/electron
|
||||
run: npm ci
|
||||
|
||||
- name: Build desktop TypeScript
|
||||
working-directory: desktop/electron
|
||||
run: npm run build
|
||||
|
||||
- name: Run desktop unit tests
|
||||
working-directory: desktop/electron
|
||||
run: |
|
||||
node scripts/test-secret-storage-policy.mjs
|
||||
node scripts/test-update-resolver.mjs
|
||||
node scripts/test-desktop-locale.mjs
|
||||
node scripts/test-desktop-profile-substrate.mjs
|
||||
node scripts/test-desktop-profile-context.mjs
|
||||
node scripts/test-desktop-cleanup-contract.mjs
|
||||
|
||||
ubuntu-quality:
|
||||
name: Lint, test, and build (ubuntu-latest, 3.12)
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.python_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: ${{ needs.classify-changes.outputs.full_required == 'true' }}
|
||||
|
||||
- name: Configure runtime directories
|
||||
shell: bash
|
||||
run: |
|
||||
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv sync --extra dev --extra recommended --extra mcp --frozen
|
||||
|
||||
- name: Lint
|
||||
shell: bash
|
||||
run: uv run ruff check src tests
|
||||
|
||||
- name: Type check
|
||||
shell: bash
|
||||
run: uv run mypy src/opensquilla --show-error-codes
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
markers="not llm and not live_search and not live_channel and not webui_browser and not tui_real_terminal and not local_golden and not agent_context_boundary and not llm_router_acc"
|
||||
|
||||
if [[ "${{ needs.classify-changes.outputs.full_required }}" == "true" ]]; then
|
||||
uv run pytest tests -q -m "${markers}" --durations=50
|
||||
else
|
||||
uv run pytest \
|
||||
tests/unit \
|
||||
tests/test_ci \
|
||||
--ignore=tests/test_ci/test_router_artifact_manifest.py \
|
||||
tests/test_desktop/test_electron_startup_contract.py \
|
||||
tests/test_recovery \
|
||||
tests/test_migration/test_opensquilla_home_migration.py \
|
||||
tests/test_engine/test_coding_mode.py \
|
||||
tests/test_engine/test_stream_wrappers.py \
|
||||
tests/test_tools/test_shell_process_isolation.py \
|
||||
-q -m "${markers}" --durations=50
|
||||
fi
|
||||
|
||||
- name: Verify metric counter names unchanged
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
for name in opensquilla_queue_depth in_flight_turns_total turn_cancellations_total queue_full_errors_total; do
|
||||
count=$(grep -c "$name" src/opensquilla/gateway/task_runtime.py || echo 0)
|
||||
if [ "$count" -lt 1 ]; then
|
||||
echo "ERROR: metric name '$name' missing from task_runtime.py"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: tracemalloc leak smoke test
|
||||
if: ${{ needs.classify-changes.outputs.full_required == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
uv run python -m pytest tests/test_gateway/test_task_runtime_terminal_cleanup.py::test_no_leak_under_load -v
|
||||
|
||||
- name: Build wheel
|
||||
if: ${{ needs.classify-changes.outputs.build_wheel_required == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
shell: bash
|
||||
run: uv build --wheel
|
||||
|
||||
windows-compat:
|
||||
name: Windows compatibility smoke (3.12)
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.python_changed == 'true' || needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.dependency_changed == 'true' || needs.classify-changes.outputs.release_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure runtime directories
|
||||
shell: bash
|
||||
run: |
|
||||
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv sync --extra dev --extra recommended --extra mcp --frozen
|
||||
|
||||
- name: Test Windows compatibility smoke
|
||||
shell: bash
|
||||
run: |
|
||||
uv run pytest \
|
||||
tests/test_artifacts.py \
|
||||
tests/test_contrib/test_codetask \
|
||||
tests/test_desktop/test_electron_startup_contract.py \
|
||||
tests/test_engine/test_coding_mode.py \
|
||||
tests/test_engine/test_stream_wrappers.py \
|
||||
tests/test_tools/test_shell_process_isolation.py \
|
||||
-q
|
||||
|
||||
windows-full:
|
||||
name: Windows high-risk (${{ matrix.shard }})
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.windows_full_required == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- core
|
||||
- gateway-sqlite
|
||||
- recovery-migration
|
||||
- desktop-installer-contracts
|
||||
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
|
||||
steps:
|
||||
- name: Prepare diagnostic report
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report_dir="${RUNNER_TEMP}/ci-reports/${{ matrix.shard }}"
|
||||
mkdir -p "${report_dir}"
|
||||
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
|
||||
{
|
||||
printf 'runner_os=%s\n' "${RUNNER_OS}"
|
||||
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
|
||||
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
|
||||
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
|
||||
printf 'shard=%s\n' "${{ matrix.shard }}"
|
||||
} > "${report_dir}/environment.txt"
|
||||
printf 'pytest_status=not_started\n' > "${report_dir}/first-failure.txt"
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: ${{ matrix.shard == 'core' }}
|
||||
|
||||
- name: Configure runtime directories
|
||||
shell: bash
|
||||
run: |
|
||||
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Bun
|
||||
if: ${{ matrix.shard == 'core' }}
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install OpenTUI host dependencies
|
||||
if: ${{ matrix.shard == 'core' }}
|
||||
shell: bash
|
||||
run: bun install --frozen-lockfile --cwd=src/opensquilla/cli/tui/opentui/package
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv sync --extra dev --extra recommended --extra mcp --frozen
|
||||
|
||||
- name: Record tool versions
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
python --version
|
||||
uv --version
|
||||
uv run pytest --version
|
||||
} >> "${CI_REPORT_DIR}/environment.txt" 2>&1
|
||||
|
||||
- name: Provision distinct Windows test volumes
|
||||
if: ${{ matrix.shard == 'recovery-migration' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$token = [guid]::NewGuid().ToString("N")
|
||||
$volumeA = Join-Path -Path $env:RUNNER_TEMP -ChildPath "opensquilla-windows-volume-a-$token"
|
||||
$volumeB = Join-Path -Path $env:LOCALAPPDATA -ChildPath "opensquilla-windows-volume-b-$token"
|
||||
|
||||
if (Test-Path -LiteralPath $volumeA) {
|
||||
throw "Windows test volume root already exists: $volumeA"
|
||||
}
|
||||
if (Test-Path -LiteralPath $volumeB) {
|
||||
throw "Windows test volume root already exists: $volumeB"
|
||||
}
|
||||
|
||||
$driveA = [System.IO.Path]::GetPathRoot($volumeA)
|
||||
$driveB = [System.IO.Path]::GetPathRoot($volumeB)
|
||||
$systemDriveRoot = [System.IO.Path]::GetPathRoot("$($env:SystemDrive)\")
|
||||
if (-not [string]::Equals($driveB, $systemDriveRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Windows test volume B must use SystemDrive"
|
||||
}
|
||||
if ([string]::Equals($driveA, $driveB, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Windows test volume roots must use different drives"
|
||||
}
|
||||
|
||||
$createdRoots = [System.Collections.Generic.List[string]]::new()
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $volumeA -ErrorAction Stop | Out-Null
|
||||
$createdRoots.Add($volumeA)
|
||||
New-Item -ItemType Directory -Path $volumeB -ErrorAction Stop | Out-Null
|
||||
$createdRoots.Add($volumeB)
|
||||
"OPENSQUILLA_WINDOWS_TEST_VOLUME_A=$volumeA" |
|
||||
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSQUILLA_WINDOWS_TEST_VOLUME_B=$volumeB" |
|
||||
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
} catch {
|
||||
foreach ($testRoot in $createdRoots) {
|
||||
if (Test-Path -LiteralPath $testRoot) {
|
||||
Remove-Item -LiteralPath $testRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
throw
|
||||
}
|
||||
|
||||
- name: Test Windows native recovery move primitives first
|
||||
if: ${{ matrix.shard == 'recovery-migration' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
maxfail_args=()
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
maxfail_args+=(--maxfail=3)
|
||||
fi
|
||||
|
||||
set +e
|
||||
uv run pytest \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_native_move_moves_a_regular_tree_between_real_parents \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_legacy_lock_survives_profile_move_and_rebind \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_replacement_locks_survive_two_profile_moves \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_recent_locked_profile_tree_moves_without_metadata_false_positive \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_windows_primitive_collision_preserves_both_trees \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_refuses_real_cross_volume_move \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_handles_real_path_longer_than_260_characters \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_rejects_real_junction_in_source_tree \
|
||||
tests/test_recovery/test_atomic_and_locking.py::test_windows_no_replace_pins_both_parents_during_real_mutation_window \
|
||||
-q \
|
||||
--durations=25 \
|
||||
--junitxml="${CI_REPORT_DIR}/native-move-junit.xml" \
|
||||
"${maxfail_args[@]}" \
|
||||
2>&1 | tee "${CI_REPORT_DIR}/native-move.log"
|
||||
status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [[ "${status}" -ne 0 ]]; then
|
||||
printf 'native_move_exit_code=%s\n' "${status}" \
|
||||
> "${CI_REPORT_DIR}/first-failure.txt"
|
||||
exit "${status}"
|
||||
fi
|
||||
|
||||
- name: Test Windows shard
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
markers="not llm and not live_search and not live_channel and not webui_browser and not tui_real_terminal and not local_golden and not agent_context_boundary and not llm_router_acc"
|
||||
maxfail_args=()
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
maxfail_args+=(--maxfail=3)
|
||||
fi
|
||||
|
||||
uv run python .github/scripts/windows_test_shards.py run \
|
||||
"${{ matrix.shard }}" \
|
||||
--junit "${CI_REPORT_DIR}/junit.xml" \
|
||||
--summary "${CI_REPORT_DIR}/first-failure.txt" \
|
||||
-- \
|
||||
-q \
|
||||
-m "${markers}" \
|
||||
--durations=50 \
|
||||
"${maxfail_args[@]}" \
|
||||
2>&1 | tee "${CI_REPORT_DIR}/pytest.log"
|
||||
|
||||
- name: Clean up Windows test volumes
|
||||
if: ${{ always() && matrix.shard == 'recovery-migration' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
$testRoots = @(
|
||||
$env:OPENSQUILLA_WINDOWS_TEST_VOLUME_A,
|
||||
$env:OPENSQUILLA_WINDOWS_TEST_VOLUME_B
|
||||
)
|
||||
foreach ($testRoot in $testRoots) {
|
||||
if ($testRoot -and (Test-Path -LiteralPath $testRoot)) {
|
||||
Remove-Item -LiteralPath $testRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
- name: Upload Windows shard report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-high-risk-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ci-reports/${{ matrix.shard }}
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
macos-recovery:
|
||||
name: macOS profile recovery and native no-replace (3.12)
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
|
||||
steps:
|
||||
- name: Prepare macOS recovery report
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report_dir="${RUNNER_TEMP}/macos-recovery"
|
||||
mkdir -p "${report_dir}"
|
||||
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
|
||||
{
|
||||
printf 'runner_os=%s\n' "${RUNNER_OS}"
|
||||
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
|
||||
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
|
||||
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
|
||||
} > "${report_dir}/environment.txt"
|
||||
printf 'pytest_status=not_started\n' > "${report_dir}/first-failure.txt"
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure runtime directories
|
||||
shell: bash
|
||||
run: |
|
||||
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv sync --extra dev --extra recommended --extra mcp --frozen
|
||||
|
||||
- name: Test native profile recovery contracts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
maxfail_args=()
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
maxfail_args+=(--maxfail=3)
|
||||
fi
|
||||
|
||||
set +e
|
||||
uv run pytest \
|
||||
tests/test_recovery \
|
||||
tests/test_migration/test_opensquilla_home_migration.py \
|
||||
tests/test_desktop/test_electron_startup_contract.py \
|
||||
-q \
|
||||
--durations=50 \
|
||||
--junitxml="${CI_REPORT_DIR}/junit.xml" \
|
||||
"${maxfail_args[@]}" \
|
||||
2>&1 | tee "${CI_REPORT_DIR}/pytest.log"
|
||||
status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
printf 'pytest_exit_code=%s\n' "${status}" > "${CI_REPORT_DIR}/first-failure.txt"
|
||||
exit "${status}"
|
||||
|
||||
- name: Upload macOS recovery report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-recovery-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/macos-recovery
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
desktop-recovery-e2e:
|
||||
name: Desktop recovery E2E (${{ matrix.os }})
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
|
||||
steps:
|
||||
- name: Prepare Desktop recovery report
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report_dir="${RUNNER_TEMP}/desktop-recovery-e2e"
|
||||
mkdir -p "${report_dir}"
|
||||
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
|
||||
{
|
||||
printf 'runner_os=%s\n' "${RUNNER_OS}"
|
||||
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
|
||||
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
|
||||
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
|
||||
} > "${report_dir}/environment.txt"
|
||||
printf 'e2e_status=not_started\n' > "${report_dir}/first-failure.txt"
|
||||
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: desktop/electron/package-lock.json
|
||||
|
||||
- name: Install Python dependencies
|
||||
shell: bash
|
||||
run: uv sync --extra dev --extra recommended --extra mcp --frozen
|
||||
|
||||
- name: Install Desktop dependencies
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
run: npm ci
|
||||
|
||||
- name: Record Desktop recovery tool versions
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
python --version
|
||||
uv --version
|
||||
node --version
|
||||
npm --version
|
||||
} >> "${CI_REPORT_DIR}/environment.txt" 2>&1
|
||||
|
||||
- name: Build Desktop TypeScript
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
run: npm run build
|
||||
|
||||
- name: Run compiled Desktop recovery flows
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
OPENSQUILLA_DESKTOP_RECOVERY_SCREENSHOT: ${{ runner.temp }}/desktop-recovery-e2e/recovery-page.png
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
run_case() {
|
||||
local name="$1"
|
||||
local script="$2"
|
||||
if [[ "${RUNNER_OS}" == "Linux" ]]; then
|
||||
xvfb-run -a node "${script}" 2>&1 | tee "${CI_REPORT_DIR}/${name}.log"
|
||||
else
|
||||
node "${script}" 2>&1 | tee "${CI_REPORT_DIR}/${name}.log"
|
||||
fi
|
||||
}
|
||||
|
||||
for entry in \
|
||||
'profile-recovery-flow:scripts/test-profile-recovery-flow.mjs' \
|
||||
'profile-recovery-accessibility:scripts/test-profile-recovery-accessibility.mjs' \
|
||||
'profile-import-flow:scripts/test-profile-import-flow.mjs' \
|
||||
'unsafe-profile-no-write:scripts/test-unsafe-profile-no-write.mjs'
|
||||
do
|
||||
name="${entry%%:*}"
|
||||
script="${entry#*:}"
|
||||
if ! run_case "${name}" "${script}"; then
|
||||
printf 'failed_case=%s\n' "${name}" > "${CI_REPORT_DIR}/first-failure.txt"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
printf 'e2e_status=passed\n' > "${CI_REPORT_DIR}/first-failure.txt"
|
||||
|
||||
- name: Upload Desktop recovery report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-recovery-e2e-${{ matrix.os }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/desktop-recovery-e2e
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
release-packaging:
|
||||
name: Release packaging contracts
|
||||
needs: classify-changes
|
||||
if: ${{ needs.classify-changes.outputs.release_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: uv sync --extra dev --extra recommended --extra mcp --frozen
|
||||
|
||||
- name: Run release packaging contract tests
|
||||
shell: bash
|
||||
run: |
|
||||
uv run pytest \
|
||||
tests/test_scripts/test_build_wheelhouse_zip.py \
|
||||
tests/test_install_scripts.py \
|
||||
tests/test_root_start_scripts.py \
|
||||
tests/test_release_consistency.py \
|
||||
tests/test_public_release_hygiene.py \
|
||||
-q
|
||||
|
||||
readme-locale-check:
|
||||
name: README locale parity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.19.0'
|
||||
|
||||
- name: Check README locale parity
|
||||
working-directory: opensquilla-webui
|
||||
run: node scripts/check-readme-locales.mjs
|
||||
|
||||
ci-result:
|
||||
name: CI result
|
||||
needs:
|
||||
- classify-changes
|
||||
- workflow-lint
|
||||
- readme-locale-check
|
||||
- frontend-check
|
||||
- tui-check
|
||||
- desktop-check
|
||||
- ubuntu-quality
|
||||
- windows-compat
|
||||
- windows-full
|
||||
- macos-recovery
|
||||
- desktop-recovery-e2e
|
||||
- release-packaging
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Check required CI results
|
||||
env:
|
||||
RESULT_CLASSIFY: ${{ needs.classify-changes.result }}
|
||||
RESULT_WORKFLOW_LINT: ${{ needs.workflow-lint.result }}
|
||||
RESULT_README_LOCALE: ${{ needs.readme-locale-check.result }}
|
||||
RESULT_FRONTEND: ${{ needs.frontend-check.result }}
|
||||
RESULT_TUI: ${{ needs.tui-check.result }}
|
||||
RESULT_DESKTOP: ${{ needs.desktop-check.result }}
|
||||
RESULT_UBUNTU: ${{ needs.ubuntu-quality.result }}
|
||||
RESULT_WINDOWS_SMOKE: ${{ needs.windows-compat.result }}
|
||||
RESULT_WINDOWS_FULL: ${{ needs.windows-full.result }}
|
||||
RESULT_MACOS_RECOVERY: ${{ needs.macos-recovery.result }}
|
||||
RESULT_DESKTOP_RECOVERY_E2E: ${{ needs.desktop-recovery-e2e.result }}
|
||||
RESULT_RELEASE: ${{ needs.release-packaging.result }}
|
||||
FLAG_DOCS_ONLY: ${{ needs.classify-changes.outputs.docs_only }}
|
||||
FLAG_RUNTIME_CHANGED: ${{ needs.classify-changes.outputs.runtime_changed }}
|
||||
FLAG_TEST_CHANGED: ${{ needs.classify-changes.outputs.test_changed }}
|
||||
FLAG_CI_CHANGED: ${{ needs.classify-changes.outputs.ci_changed }}
|
||||
FLAG_DEPENDENCY_CHANGED: ${{ needs.classify-changes.outputs.dependency_changed }}
|
||||
FLAG_RELEASE_CHANGED: ${{ needs.classify-changes.outputs.release_changed }}
|
||||
FLAG_WINDOWS_FULL_REQUIRED: ${{ needs.classify-changes.outputs.windows_full_required }}
|
||||
FLAG_FRONTEND_CHANGED: ${{ needs.classify-changes.outputs.frontend_changed }}
|
||||
FLAG_TUI_CHANGED: ${{ needs.classify-changes.outputs.tui_changed }}
|
||||
FLAG_DESKTOP_CHANGED: ${{ needs.classify-changes.outputs.desktop_changed }}
|
||||
FLAG_PYTHON_CHANGED: ${{ needs.classify-changes.outputs.python_changed }}
|
||||
FLAG_PLATFORM_SENSITIVE_CHANGED: ${{ needs.classify-changes.outputs.platform_sensitive_changed }}
|
||||
FLAG_BUILD_WHEEL_REQUIRED: ${{ needs.classify-changes.outputs.build_wheel_required }}
|
||||
FLAG_FULL_REQUIRED: ${{ needs.classify-changes.outputs.full_required }}
|
||||
run: python .github/scripts/check_ci_results.py
|
||||
@@ -0,0 +1,234 @@
|
||||
name: Container Images
|
||||
|
||||
# Builds the multi-arch (linux/amd64 + linux/arm64) gateway image and
|
||||
# publishes it to GHCR so home-server/NAS users can `docker pull` a release
|
||||
# instead of building from a source checkout. See docs/docker.md.
|
||||
#
|
||||
# Publishing model:
|
||||
# * Pushing a release tag (v0.5.0rc3, v0.6.0, ...) first publishes the
|
||||
# immutable ghcr.io/<owner>/<repo>:<tag> image. The workflow verifies its
|
||||
# amd64/arm64 manifest and container HEALTHCHECK before moving :latest.
|
||||
# :latest tracks the most recently pushed release tag — including
|
||||
# prereleases and backports; if a backport repoints it, re-run this
|
||||
# workflow from the newest tag.
|
||||
# * workflow_dispatch validates the full multi-arch build without
|
||||
# publishing; set publish=true to push the result as :edge.
|
||||
#
|
||||
# The GITHUB_TOKEN with job-level `packages: write` is the only credential.
|
||||
# First-time note for maintainers: a new GHCR package is created private —
|
||||
# flip ghcr.io/<owner>/<repo> to public in the package settings once.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: Push the built image to GHCR as the edge tag (leave off to only validate the build)
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: container-images-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
name: Build multi-arch gateway image
|
||||
runs-on: ubuntu-latest
|
||||
# The arm64 leg runs under QEMU emulation. All compiled dependencies
|
||||
# install from prebuilt aarch64 wheels, so this is emulated pip time,
|
||||
# not source builds — slow but bounded.
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Validate release tag
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+.*$ ]]; then
|
||||
echo "Release tag must look like v0.2.0rc1 or v0.2.0: ${GITHUB_REF_NAME}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check tag version
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
version="$(python3 - <<'PY'
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
print(project["project"]["version"])
|
||||
PY
|
||||
)"
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
if [[ "${tag_version}" != "${version}" ]]; then
|
||||
echo "Tag ${GITHUB_REF_NAME} does not match project version ${version}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Hydrate router assets
|
||||
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
|
||||
|
||||
# The Dockerfile hard-fails on LFS pointer files; failing here first
|
||||
# gives a clearer error than a mid-build SystemExit.
|
||||
- name: Verify router assets are hydrated
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
root = Path("src/opensquilla/squilla_router/models/v4.2_phase3_inference")
|
||||
required = [
|
||||
root / "PROVENANCE.md",
|
||||
root / "artifact_manifest.json",
|
||||
root / "bge_onnx" / "model.onnx",
|
||||
root / "features" / "tfidf.pkl",
|
||||
root / "lgbm_main.bin",
|
||||
root / "mlp" / "model.onnx",
|
||||
root / "router.runtime.yaml",
|
||||
]
|
||||
for path in required:
|
||||
assert path.is_file(), f"missing router asset: {path}"
|
||||
prefix = path.read_bytes()[:80]
|
||||
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
|
||||
f"Git LFS pointer file is not hydrated: {path}"
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: ${{ github.event_name == 'push' || inputs.publish == true }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Release tags are PEP 440 (v0.5.0rc3), not strict semver, so the git
|
||||
# tag is mapped through as-is instead of via type=semver.
|
||||
- name: Compute image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag,enable=${{ github.event_name == 'push' }}
|
||||
type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
- name: Build multi-arch image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'push' || inputs.publish == true }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Keep the pushed artifact a plain two-entry manifest list; the
|
||||
# default provenance attestation adds an unknown/unknown entry that
|
||||
# confuses registry UIs and `docker pull` inspection.
|
||||
provenance: false
|
||||
|
||||
- name: Select pushed image
|
||||
if: ${{ github.event_name == 'push' || inputs.publish == true }}
|
||||
id: pushed_image
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
|
||||
image_tag="${GITHUB_REF_NAME}"
|
||||
else
|
||||
image_tag="edge"
|
||||
fi
|
||||
echo "ref=ghcr.io/${GITHUB_REPOSITORY}:${image_tag}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Verify pushed manifest platforms
|
||||
if: ${{ github.event_name == 'push' || inputs.publish == true }}
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
image_ref = os.environ["IMAGE_REF"]
|
||||
manifest = json.loads(
|
||||
subprocess.check_output(
|
||||
["docker", "buildx", "imagetools", "inspect", image_ref, "--raw"],
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
platforms = {
|
||||
f'{item["platform"]["os"]}/{item["platform"]["architecture"]}'
|
||||
for item in manifest.get("manifests", [])
|
||||
}
|
||||
expected = {"linux/amd64", "linux/arm64"}
|
||||
if platforms != expected:
|
||||
raise SystemExit(
|
||||
f"{image_ref} has platforms {sorted(platforms)}; "
|
||||
f"expected {sorted(expected)}"
|
||||
)
|
||||
print(f"Verified {image_ref}: {sorted(platforms)}")
|
||||
PY
|
||||
|
||||
- name: Smoke pushed image HEALTHCHECK
|
||||
if: ${{ github.event_name == 'push' || inputs.publish == true }}
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
container_id="$(docker run --detach --pull=always "${IMAGE_REF}")"
|
||||
trap 'docker rm --force "${container_id}" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
deadline=$((SECONDS + 180))
|
||||
while (( SECONDS < deadline )); do
|
||||
running="$(docker inspect --format '{{.State.Running}}' "${container_id}")"
|
||||
health="$(docker inspect \
|
||||
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \
|
||||
"${container_id}")"
|
||||
echo "Container health: ${health}"
|
||||
if [[ "${health}" == "healthy" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${running}" != "true" || "${health}" == "unhealthy" || "${health}" == "missing" ]]; then
|
||||
docker logs "${container_id}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "Container did not become healthy within 180 seconds" >&2
|
||||
docker logs "${container_id}"
|
||||
exit 1
|
||||
|
||||
# Promotion happens only after the immutable release image passes both
|
||||
# remote-manifest verification and its image-defined HEALTHCHECK.
|
||||
- name: Promote verified release image to latest
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
|
||||
LATEST_REF: ghcr.io/${{ github.repository }}:latest
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--tag "${LATEST_REF}" \
|
||||
"${IMAGE_REF}"
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Issue Link Sync
|
||||
|
||||
"on":
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited, closed]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-linked-issues:
|
||||
name: Sync linked issue state
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted base branch scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# pull_request_target has write-capable tokens. Run scripts from the
|
||||
# pre-merge base commit, never from the pull request head.
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Sync issue labels and comments
|
||||
env:
|
||||
GITHUB_EVENT_PATH: ${{ github.event_path }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/scripts/issue_link_sync.py
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Live Release E2E
|
||||
|
||||
"on":
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_telegram_channel:
|
||||
description: "Send one real Telegram smoke message"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
live-release-e2e:
|
||||
name: Maintainer live release gates
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
|
||||
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
|
||||
OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN: ${{ secrets.OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN }}
|
||||
OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID: ${{ secrets.OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID }}
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure runtime directories
|
||||
shell: bash
|
||||
run: |
|
||||
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra recommended --frozen
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx --yes playwright install chromium
|
||||
|
||||
- name: Fail if OpenRouter secret is missing
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "$OPENROUTER_API_KEY" ]; then
|
||||
echo "OPENROUTER_API_KEY GitHub secret is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fail if Telegram secrets are missing when channel smoke is enabled
|
||||
if: ${{ inputs.run_telegram_channel }}
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "$OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN" ]; then
|
||||
echo "OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN GitHub secret is required when Telegram smoke is enabled"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID" ]; then
|
||||
echo "OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID GitHub secret is required when Telegram smoke is enabled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run gateway LLM e2e
|
||||
env:
|
||||
OPENSQUILLA_GATEWAY_LLM_E2E: "1"
|
||||
run: uv run pytest tests/functional/test_gateway_llm_e2e.py -q -s
|
||||
|
||||
- name: Run real-browser chat e2e
|
||||
env:
|
||||
OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E: "1"
|
||||
run: uv run pytest tests/functional/test_webui_browser_chat_e2e.py -q -s
|
||||
|
||||
- name: Run live Telegram channel smoke
|
||||
if: ${{ inputs.run_telegram_channel }}
|
||||
env:
|
||||
OPENSQUILLA_LIVE_TELEGRAM_E2E: "1"
|
||||
run: uv run pytest tests/functional/test_live_channel_telegram_smoke.py -q -s
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Live Search E2E
|
||||
|
||||
"on":
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_extended_matrix:
|
||||
description: "Run the broader live provider/API matrix"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
live-search-e2e:
|
||||
name: Live search provider and agent gates
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
OPENSQUILLA_LIVE_SEARCH: "1"
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
|
||||
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
|
||||
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
|
||||
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
|
||||
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
|
||||
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure runtime directories
|
||||
shell: bash
|
||||
run: |
|
||||
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra recommended --frozen
|
||||
|
||||
- name: Fail if required live search secrets are missing
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -z "$OPENROUTER_API_KEY" ]; then
|
||||
echo "OPENROUTER_API_KEY GitHub secret is required"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$TAVILY_API_KEY" ]; then
|
||||
echo "TAVILY_API_KEY GitHub secret is required"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
|
||||
&& [ -z "$BRAVE_SEARCH_API_KEY" ]; then
|
||||
echo "BRAVE_SEARCH_API_KEY GitHub secret is required for the extended matrix"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
|
||||
&& [ -z "$EXA_API_KEY" ]; then
|
||||
echo "EXA_API_KEY GitHub secret is required for the extended matrix"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
|
||||
&& [ -z "$FIRECRAWL_API_KEY" ]; then
|
||||
echo "FIRECRAWL_API_KEY GitHub secret is required for the extended matrix"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run core live search gates
|
||||
run: |
|
||||
timeout 180s uv run pytest \
|
||||
tests/live/test_search_retrieval_live.py \
|
||||
tests/live/test_web_search_agent_e2e.py \
|
||||
-m live_search \
|
||||
-q -s
|
||||
|
||||
- name: Run extended live search matrix
|
||||
if: ${{ inputs.run_extended_matrix }}
|
||||
env:
|
||||
OPENSQUILLA_LIVE_SEARCH_MATRIX: "1"
|
||||
run: |
|
||||
timeout 240s uv run pytest \
|
||||
tests/live/test_search_api_matrix_live.py \
|
||||
-m live_search \
|
||||
-q -s
|
||||
@@ -0,0 +1,42 @@
|
||||
name: LLM Smoke
|
||||
|
||||
"on":
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
llm-smoke:
|
||||
name: Live LLM smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
|
||||
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
TMPDIR: /tmp
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure runtime directories
|
||||
shell: bash
|
||||
run: printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: uv sync --extra dev --frozen
|
||||
|
||||
- name: Run smoke
|
||||
run: uv run pytest tests/functional/test_llm_smoke.py -q -s
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Models.dev Snapshot Refresh
|
||||
|
||||
"on":
|
||||
schedule:
|
||||
# Monthly, 06:00 UTC on the 1st. Data-only refresh of the vendored
|
||||
# models.dev snapshot; the PR it opens still goes through normal review.
|
||||
- cron: "0 6 1 * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# The job fetches public catalog data and opens a PR with the diff. It must
|
||||
# never receive provider API keys or other live-test secrets — only the
|
||||
# workflow GITHUB_TOKEN, scoped to branch + PR creation.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh-snapshot:
|
||||
name: Refresh vendored models.dev snapshot
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# Base dependencies only: the refresh script needs the provider
|
||||
# registry and httpx, nothing from the dev/recommended extras.
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Refresh models.dev snapshot
|
||||
shell: bash
|
||||
run: uv run python scripts/refresh_models_dev_snapshot.py
|
||||
|
||||
- name: Open data-refresh pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
branch: data/models-dev-snapshot-refresh
|
||||
delete-branch: true
|
||||
add-paths: src/opensquilla/provider/models_dev_snapshot.json
|
||||
commit-message: Refresh vendored models.dev snapshot
|
||||
title: Refresh vendored models.dev snapshot (scheduled data refresh)
|
||||
body: |
|
||||
Scheduled data-only refresh of the vendored models.dev snapshot
|
||||
(`src/opensquilla/provider/models_dev_snapshot.json`), produced by
|
||||
`uv run python scripts/refresh_models_dev_snapshot.py`.
|
||||
|
||||
- Scope: vendored model-metadata snapshot only; no runtime code
|
||||
changes.
|
||||
- Branch target: main
|
||||
- Linked issue: None
|
||||
- Release notes: not needed (data refresh).
|
||||
- Tests: default CI on this PR revalidates the snapshot shape.
|
||||
- Safety: **needs human review before merge** — upstream data
|
||||
mistakes (context windows, output caps) poison context budgeting
|
||||
at runtime. Review the diff line by line; the snapshot is kept
|
||||
deliberately small for this reason.
|
||||
- Third-party origin: models.dev api.json (MIT, maintained by the
|
||||
SST team).
|
||||
@@ -0,0 +1,33 @@
|
||||
name: PR body lint
|
||||
|
||||
"on":
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-body:
|
||||
name: Validate PR body fields
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted workflow scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out bootstrap body linter before first merge
|
||||
if: ${{ hashFiles('.github/scripts/validate_pr_body.py') == '' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate PR body fields
|
||||
env:
|
||||
PR_BODY_LINT_STRICT: "0"
|
||||
run: python3 .github/scripts/validate_pr_body.py
|
||||
@@ -0,0 +1,37 @@
|
||||
name: PR target branch
|
||||
|
||||
"on":
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
validate-target:
|
||||
name: Validate target branch
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out trusted workflow scripts
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check out bootstrap validator before first merge
|
||||
if: ${{ hashFiles('.github/scripts/validate-pr-target-branch.sh') == '' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate target branch
|
||||
env:
|
||||
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: bash .github/scripts/validate-pr-target-branch.sh
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Web UI Browser Smoke
|
||||
|
||||
"on":
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
webui-browser-smoke:
|
||||
name: Real browser Web UI smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
OPENSQUILLA_WEBUI_BROWSER_E2E: "1"
|
||||
OPENSQUILLA_TURN_CALL_LOG: "0"
|
||||
TMPDIR: /tmp
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure runtime directories
|
||||
shell: bash
|
||||
run: printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: uv sync --extra dev --frozen
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx --yes playwright install chromium
|
||||
|
||||
- name: Run Web UI browser smoke
|
||||
run: uv run pytest tests/functional/test_webui_browser_e2e.py -q -s
|
||||
@@ -0,0 +1,875 @@
|
||||
name: Release Assets
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Optional existing tag to upload artifacts to
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-assets-${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RELEASE_PROFILE: recommended
|
||||
RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag }}
|
||||
|
||||
jobs:
|
||||
build-release-assets:
|
||||
name: Build Python release assets
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- name: Validate workflow inputs
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n "${RELEASE_TAG}" && ! "${RELEASE_TAG}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+.*$ ]]; then
|
||||
echo "Release tag must look like v0.2.0rc1 or v0.2.0: ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
|
||||
|
||||
- name: Hydrate router assets
|
||||
shell: bash
|
||||
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
|
||||
|
||||
- name: Verify router assets are hydrated
|
||||
shell: bash
|
||||
run: |
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
root = Path("src/opensquilla/squilla_router/models/v4.2_phase3_inference")
|
||||
required = [
|
||||
root / "PROVENANCE.md",
|
||||
root / "artifact_manifest.json",
|
||||
root / "bge_onnx" / "model.onnx",
|
||||
root / "features" / "tfidf.pkl",
|
||||
root / "lgbm_main.bin",
|
||||
root / "mlp" / "model.onnx",
|
||||
root / "router.runtime.yaml",
|
||||
]
|
||||
for path in required:
|
||||
assert path.is_file(), f"missing router asset: {path}"
|
||||
prefix = path.read_bytes()[:80]
|
||||
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
|
||||
f"Git LFS pointer file is not hydrated: {path}"
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: python -m pip install uv
|
||||
|
||||
- name: Check tag version
|
||||
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(python - <<'PY'
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
print(pyproject["project"]["version"])
|
||||
PY
|
||||
)"
|
||||
tag="${RELEASE_TAG}"
|
||||
tag_version="${tag#v}"
|
||||
if [[ "${tag_version}" != "${version}" ]]; then
|
||||
echo "Tag ${tag} does not match project version ${version}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test release asset contracts
|
||||
run: uv run --extra dev pytest tests/test_scripts/test_build_wheelhouse_zip.py
|
||||
|
||||
- name: Build versioned wheel
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf dist build/wheelhouse-zip
|
||||
uv build --wheel --out-dir dist
|
||||
|
||||
- name: Smoke versioned release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
version = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"]
|
||||
wheels = sorted(
|
||||
path for path in Path("dist").glob("opensquilla-*.whl")
|
||||
)
|
||||
assert len(wheels) == 1, f"expected one versioned wheel, got {len(wheels)}"
|
||||
assert wheels[0].name == f"opensquilla-{version}-py3-none-any.whl"
|
||||
assert not list(Path("dist").glob("OpenSquilla-*portable*.zip")), (
|
||||
"0.5+ release assets must not include Windows portable zips"
|
||||
)
|
||||
|
||||
with ZipFile(wheels[0]) as archive:
|
||||
for info in archive.infolist():
|
||||
if not info.filename.endswith((".bin", ".onnx", ".pkl", ".joblib")):
|
||||
continue
|
||||
prefix = archive.read(info)[:80]
|
||||
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
|
||||
f"Git LFS pointer leaked into wheel: {info.filename}"
|
||||
)
|
||||
PY
|
||||
|
||||
- name: Create release assets
|
||||
shell: bash
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
dist = Path("dist")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
|
||||
version = project["project"]["version"]
|
||||
wheels = sorted(
|
||||
path for path in dist.glob("opensquilla-*.whl")
|
||||
)
|
||||
assert len(wheels) == 1, f"expected one versioned wheel, got {len(wheels)}"
|
||||
assert wheels[0].name == f"opensquilla-{version}-py3-none-any.whl"
|
||||
assets = [wheels[0]]
|
||||
lines = [
|
||||
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}"
|
||||
for path in assets
|
||||
]
|
||||
(dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opensquilla-release-assets-python-${{ env.RELEASE_PROFILE }}
|
||||
path: |
|
||||
dist/*.whl
|
||||
dist/SHA256SUMS
|
||||
|
||||
build-desktop-macos:
|
||||
name: Build macOS Electron installer
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
|
||||
|
||||
- name: Hydrate router assets
|
||||
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.12.0"
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
opensquilla-webui/package-lock.json
|
||||
desktop/electron/package-lock.json
|
||||
|
||||
- name: Install Web UI dependencies
|
||||
working-directory: opensquilla-webui
|
||||
run: npm ci
|
||||
|
||||
- name: Install Electron dependencies
|
||||
working-directory: desktop/electron
|
||||
run: npm ci
|
||||
|
||||
- name: Build signed macOS installer
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
npm run build:web
|
||||
npm run build:gateway
|
||||
npm run build
|
||||
npx electron-builder --mac --publish never
|
||||
|
||||
- name: Verify Electron package
|
||||
working-directory: desktop/electron
|
||||
run: npm run verify:package
|
||||
|
||||
- name: Smoke packaged gateway
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
OPENSQUILLA_REQUIRE_PACKAGED_GATEWAY_SMOKE: "1"
|
||||
OPENSQUILLA_GATEWAY_SMOKE_TIMEOUT_MS: "240000"
|
||||
run: npm run verify:gateway-smoke
|
||||
|
||||
- name: Verify RC3-to-candidate macOS upgrade and uninstall preserve profile data
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
candidates=(dist/desktop-electron/OpenSquilla-*-mac-arm64.dmg)
|
||||
if [[ "${#candidates[@]}" -ne 1 || ! -f "${candidates[0]}" ]]; then
|
||||
echo "Expected exactly one candidate macOS DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
.github/scripts/verify-release-macos-upgrade.sh \
|
||||
"${candidates[0]}" built-candidate
|
||||
|
||||
- name: List macOS artifacts
|
||||
run: find dist/desktop-electron -maxdepth 2 -type f -print
|
||||
|
||||
- name: Upload macOS Electron artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opensquilla-electron-macos
|
||||
path: |
|
||||
dist/desktop-electron/*.dmg
|
||||
dist/desktop-electron/*.zip
|
||||
dist/desktop-electron/*.blockmap
|
||||
dist/desktop-electron/latest-mac.yml
|
||||
|
||||
build-desktop-windows:
|
||||
name: Build unsigned Windows Electron installer
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
|
||||
|
||||
- name: Hydrate router assets
|
||||
shell: bash
|
||||
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.12.0"
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
opensquilla-webui/package-lock.json
|
||||
desktop/electron/package-lock.json
|
||||
|
||||
- name: Install Web UI dependencies
|
||||
working-directory: opensquilla-webui
|
||||
run: npm ci
|
||||
|
||||
- name: Install Electron dependencies
|
||||
working-directory: desktop/electron
|
||||
run: npm ci
|
||||
|
||||
- name: Build unsigned Windows installer
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run build:web
|
||||
npm run build:gateway
|
||||
npm run build
|
||||
npx electron-builder --win --publish never
|
||||
|
||||
- name: Verify Electron package
|
||||
working-directory: desktop/electron
|
||||
run: npm run verify:package
|
||||
|
||||
- name: Smoke packaged gateway
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
OPENSQUILLA_REQUIRE_PACKAGED_GATEWAY_SMOKE: "1"
|
||||
run: npm run verify:gateway-smoke
|
||||
|
||||
- name: Verify RC3-to-candidate Windows upgrade and uninstall preserve profile data
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$candidates = @(Get-ChildItem 'dist/desktop-electron/OpenSquilla-*-win-x64.exe' -File)
|
||||
if ($candidates.Count -ne 1) {
|
||||
throw "Expected exactly one candidate Windows installer; got $($candidates.Count)."
|
||||
}
|
||||
.github/scripts/verify-release-windows-upgrade.ps1 `
|
||||
-CandidateInstaller $candidates[0].FullName `
|
||||
-Label built-candidate
|
||||
|
||||
- name: List Windows artifacts
|
||||
shell: bash
|
||||
run: find dist/desktop-electron -maxdepth 2 -type f -print
|
||||
|
||||
- name: Upload Windows Electron artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opensquilla-electron-windows
|
||||
path: |
|
||||
dist/desktop-electron/*.exe
|
||||
dist/desktop-electron/*.blockmap
|
||||
dist/desktop-electron/latest.yml
|
||||
|
||||
publish-release:
|
||||
name: Publish release assets
|
||||
needs:
|
||||
- build-release-assets
|
||||
- build-desktop-macos
|
||||
- build-desktop-windows
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout release notes
|
||||
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_TAG }}
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: opensquilla-release-assets-python-${{ env.RELEASE_PROFILE }}
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download macOS Electron assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: opensquilla-electron-macos
|
||||
path: dist
|
||||
|
||||
- name: Download Windows Electron assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: opensquilla-electron-windows
|
||||
path: dist
|
||||
|
||||
- name: Regenerate release checksums
|
||||
shell: bash
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
def desktop_asset_version(version: str) -> str:
|
||||
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
|
||||
|
||||
dist = Path("dist")
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
if tag:
|
||||
version = tag.removeprefix("v")
|
||||
else:
|
||||
wheels = sorted(dist.glob("opensquilla-*-py3-none-any.whl"))
|
||||
assert len(wheels) == 1, f"expected one wheel when RELEASE_TAG is empty, got {len(wheels)}"
|
||||
version = wheels[0].name.removeprefix("opensquilla-").removesuffix("-py3-none-any.whl")
|
||||
desktop_version = desktop_asset_version(version)
|
||||
asset_names = [
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
|
||||
"latest-mac.yml",
|
||||
f"OpenSquilla-{desktop_version}-win-x64.exe",
|
||||
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
|
||||
"latest.yml",
|
||||
f"opensquilla-{version}-py3-none-any.whl",
|
||||
]
|
||||
lines = []
|
||||
for name in asset_names:
|
||||
path = dist / name
|
||||
assert path.is_file(), f"missing release asset for checksum: {name}"
|
||||
lines.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {name}")
|
||||
(dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
- name: Verify release asset set
|
||||
shell: bash
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
def desktop_asset_version(version: str) -> str:
|
||||
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
|
||||
|
||||
dist = Path("dist")
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
if tag:
|
||||
version = tag.removeprefix("v")
|
||||
else:
|
||||
wheels = sorted(dist.glob("opensquilla-*-py3-none-any.whl"))
|
||||
assert len(wheels) == 1, f"expected one wheel when RELEASE_TAG is empty, got {len(wheels)}"
|
||||
version = wheels[0].name.removeprefix("opensquilla-").removesuffix("-py3-none-any.whl")
|
||||
desktop_version = desktop_asset_version(version)
|
||||
sha256s = dist / "SHA256SUMS"
|
||||
asset_names = [
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
|
||||
"latest-mac.yml",
|
||||
f"OpenSquilla-{desktop_version}-win-x64.exe",
|
||||
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
|
||||
"latest.yml",
|
||||
f"opensquilla-{version}-py3-none-any.whl",
|
||||
]
|
||||
assets = [dist / name for name in asset_names]
|
||||
assert sha256s.is_file(), "missing SHA256SUMS"
|
||||
for path in assets:
|
||||
assert path.is_file(), f"missing release asset: {path.name}"
|
||||
expected = [
|
||||
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}"
|
||||
for path in assets
|
||||
]
|
||||
assert sha256s.read_text(encoding="utf-8").splitlines() == expected
|
||||
PY
|
||||
|
||||
- name: Upload aggregate workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opensquilla-release-assets-${{ env.RELEASE_PROFILE }}
|
||||
path: dist/*
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
TAG: ${{ env.RELEASE_TAG }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IS_PRERELEASE="$(python - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
tag = os.environ["TAG"]
|
||||
preview = re.search(
|
||||
r"(?:[-.]?(?:a|alpha|b|beta|rc|pre|preview))[0-9]+$",
|
||||
tag,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
print("true" if preview else "false")
|
||||
PY
|
||||
)"
|
||||
TITLE="OpenSquilla ${TAG#v}"
|
||||
NOTES_FILE="docs/releases/${TAG#v}.md"
|
||||
create_args=("${TAG}" --draft --verify-tag)
|
||||
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
||||
TITLE="$(python - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
version = os.environ["TAG"].removeprefix("v")
|
||||
match = re.fullmatch(r"([0-9]+[.][0-9]+[.][0-9]+)(?:a|b|rc)([0-9]+)", version)
|
||||
if match:
|
||||
print(f"OpenSquilla {match.group(1)} Preview {match.group(2)}")
|
||||
else:
|
||||
print(f"OpenSquilla {version} Preview")
|
||||
PY
|
||||
)"
|
||||
create_args+=(--prerelease)
|
||||
fi
|
||||
create_args+=(--title "${TITLE}")
|
||||
if [[ -f "${NOTES_FILE}" ]]; then
|
||||
create_args+=(--notes-file "${NOTES_FILE}")
|
||||
fi
|
||||
|
||||
assert_release_is_safe_draft() {
|
||||
local release_state
|
||||
release_state="$(gh release view "${TAG}" --json isDraft,isPrerelease)"
|
||||
RELEASE_STATE="${release_state}" EXPECTED_PRERELEASE="${IS_PRERELEASE}" python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
state = json.loads(os.environ["RELEASE_STATE"])
|
||||
expected_prerelease = os.environ["EXPECTED_PRERELEASE"] == "true"
|
||||
if state.get("isDraft") is not True:
|
||||
raise SystemExit("Refusing to mutate an existing non-Draft GitHub Release")
|
||||
if state.get("isPrerelease") is not expected_prerelease:
|
||||
raise SystemExit(
|
||||
"Refusing to mutate a GitHub Release with an unexpected prerelease state"
|
||||
)
|
||||
PY
|
||||
}
|
||||
|
||||
if gh release view "${TAG}" --json isDraft,isPrerelease >/dev/null 2>&1; then
|
||||
assert_release_is_safe_draft
|
||||
else
|
||||
gh release create "${create_args[@]}"
|
||||
assert_release_is_safe_draft
|
||||
fi
|
||||
if [[ -f "${NOTES_FILE}" ]]; then
|
||||
gh release edit "${TAG}" --title "${TITLE}" --notes-file "${NOTES_FILE}"
|
||||
fi
|
||||
assert_release_is_safe_draft
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
tag = os.environ["TAG"]
|
||||
raw = subprocess.check_output(
|
||||
["gh", "release", "view", tag, "--json", "assets"],
|
||||
text=True,
|
||||
)
|
||||
data = json.loads(raw)
|
||||
for asset in data.get("assets", []):
|
||||
name = asset["name"]
|
||||
managed = (
|
||||
name == "SHA256SUMS"
|
||||
or name == "latest-mac.yml"
|
||||
or name == "latest.yml"
|
||||
or name.startswith("OpenSquilla-")
|
||||
or name.startswith("opensquilla-")
|
||||
or name.endswith(".sha256")
|
||||
)
|
||||
if managed:
|
||||
subprocess.run(
|
||||
["gh", "release", "delete-asset", tag, name, "--yes"],
|
||||
check=True,
|
||||
)
|
||||
PY
|
||||
assert_release_is_safe_draft
|
||||
gh release upload "${TAG}" dist/* --clobber
|
||||
assert_release_is_safe_draft
|
||||
|
||||
- name: Verify GitHub Release assets
|
||||
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
TAG: ${{ env.RELEASE_TAG }}
|
||||
shell: bash
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
def desktop_asset_version(version: str) -> str:
|
||||
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
|
||||
|
||||
tag = os.environ["TAG"]
|
||||
version = tag.removeprefix("v")
|
||||
desktop_version = desktop_asset_version(version)
|
||||
expected = {
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
|
||||
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
|
||||
"latest-mac.yml",
|
||||
f"OpenSquilla-{desktop_version}-win-x64.exe",
|
||||
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
|
||||
"latest.yml",
|
||||
f"opensquilla-{version}-py3-none-any.whl",
|
||||
"SHA256SUMS",
|
||||
}
|
||||
raw = subprocess.check_output(
|
||||
[
|
||||
"gh",
|
||||
"release",
|
||||
"view",
|
||||
tag,
|
||||
"--json",
|
||||
"assets,isDraft,isPrerelease",
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
data = json.loads(raw)
|
||||
preview = re.search(
|
||||
r"(?:[-.]?(?:a|alpha|b|beta|rc|pre|preview))[0-9]+$",
|
||||
tag,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if data.get("isDraft") is not True:
|
||||
print("Release asset verification requires a Draft release", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
if data.get("isPrerelease") is not bool(preview):
|
||||
print("Release prerelease state does not match the tag", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
names = {asset["name"] for asset in data.get("assets", [])}
|
||||
missing = sorted(expected - names)
|
||||
unexpected = sorted(names - expected)
|
||||
if missing or unexpected:
|
||||
print(
|
||||
"Unexpected GitHub Release assets:",
|
||||
{
|
||||
"missing": missing,
|
||||
"unexpected": unexpected,
|
||||
"names": sorted(names),
|
||||
},
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
audit-downloaded-macos-release:
|
||||
name: Audit downloaded macOS draft assets
|
||||
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
|
||||
needs: publish-release
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout release verification helpers
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_TAG }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node.js for app.asar verification
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.12.0"
|
||||
|
||||
- name: Download actual draft release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
TAG: ${{ env.RELEASE_TAG }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
state="$(gh release view "${TAG}" --json isDraft)"
|
||||
RELEASE_STATE="${state}" python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
assert json.loads(os.environ["RELEASE_STATE"])["isDraft"] is True, (
|
||||
"downloaded release audit may only inspect a Draft release"
|
||||
)
|
||||
PY
|
||||
mkdir -p release-audit
|
||||
gh release download "${TAG}" --dir release-audit
|
||||
|
||||
- name: Verify checksums, signing, notarization, and packaged version
|
||||
env:
|
||||
TAG: ${{ env.RELEASE_TAG }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-audit
|
||||
python - <<'PY'
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
lines = Path("SHA256SUMS").read_text(encoding="utf-8").splitlines()
|
||||
assert lines, "SHA256SUMS is empty"
|
||||
for line in lines:
|
||||
digest, name = line.split(" ", 1)
|
||||
path = Path(name)
|
||||
assert path.is_file(), f"downloaded release asset is missing: {name}"
|
||||
assert sha256(path.read_bytes()).hexdigest() == digest, (
|
||||
f"checksum mismatch for downloaded asset: {name}"
|
||||
)
|
||||
PY
|
||||
|
||||
dmg_candidates=(OpenSquilla-*-mac-arm64.dmg)
|
||||
zip_candidates=(OpenSquilla-*-mac-arm64.zip)
|
||||
if [[ "${#dmg_candidates[@]}" -ne 1 || ! -f "${dmg_candidates[0]}" ]]; then
|
||||
echo "Expected exactly one downloaded macOS DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${#zip_candidates[@]}" -ne 1 || ! -f "${zip_candidates[0]}" ]]; then
|
||||
echo "Expected exactly one downloaded macOS ZIP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
expected_version="$(python - "${TAG#v}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
print(re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", sys.argv[1]))
|
||||
PY
|
||||
)"
|
||||
|
||||
validate_app() {
|
||||
local app_path plist actual_version asar_dir
|
||||
app_path="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
plist="${app_path}/Contents/Info.plist"
|
||||
actual_version="$(/usr/libexec/PlistBuddy \
|
||||
-c 'Print :CFBundleShortVersionString' "${plist}")"
|
||||
test "${actual_version}" = "${expected_version}"
|
||||
asar_dir="$(mktemp -d)"
|
||||
(
|
||||
cd "${asar_dir}"
|
||||
npx --yes @electron/asar@3.4.1 extract-file \
|
||||
"${app_path}/Contents/Resources/app.asar" package.json
|
||||
python - "${expected_version}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
packaged = json.loads(Path("package.json").read_text(encoding="utf-8"))
|
||||
assert packaged["version"] == sys.argv[1], (
|
||||
f"app.asar version {packaged['version']!r} != expected {sys.argv[1]!r}"
|
||||
)
|
||||
PY
|
||||
)
|
||||
python - "${asar_dir}" <<'PY'
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
shutil.rmtree(sys.argv[1])
|
||||
PY
|
||||
codesign --verify --deep --strict --verbose=2 "${app_path}"
|
||||
spctl -a -vv -t exec "${app_path}"
|
||||
xcrun stapler validate "${app_path}"
|
||||
}
|
||||
|
||||
extracted="$(mktemp -d)"
|
||||
ditto -x -k "${zip_candidates[0]}" "${extracted}"
|
||||
validate_app "${extracted}/OpenSquilla.app"
|
||||
|
||||
mounted="$(mktemp -d)"
|
||||
cleanup_mount() {
|
||||
hdiutil detach "${mounted}" -quiet >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup_mount EXIT
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "${mounted}" "${dmg_candidates[0]}"
|
||||
validate_app "${mounted}/OpenSquilla.app"
|
||||
hdiutil detach "${mounted}" -quiet
|
||||
|
||||
- name: Verify downloaded RC3-to-candidate macOS upgrade and uninstall
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
candidates=(release-audit/OpenSquilla-*-mac-arm64.dmg)
|
||||
if [[ "${#candidates[@]}" -ne 1 || ! -f "${candidates[0]}" ]]; then
|
||||
echo "Expected exactly one downloaded candidate macOS DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
.github/scripts/verify-release-macos-upgrade.sh \
|
||||
"${candidates[0]}" downloaded-asset
|
||||
|
||||
audit-downloaded-windows-release:
|
||||
name: Audit downloaded Windows draft asset
|
||||
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
|
||||
needs: publish-release
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout release verification helpers
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
persist-credentials: false
|
||||
ref: ${{ env.RELEASE_TAG }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Download actual draft release assets
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
TAG: ${{ env.RELEASE_TAG }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$state = gh release view $env:TAG --json isDraft | ConvertFrom-Json
|
||||
if (-not $state.isDraft) {
|
||||
throw 'Downloaded release audit may only inspect a Draft release.'
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path release-audit | Out-Null
|
||||
gh release download $env:TAG --dir release-audit
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to download Draft release assets.' }
|
||||
|
||||
- name: Verify downloaded checksums
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$checksumLines = Get-Content -LiteralPath 'release-audit/SHA256SUMS'
|
||||
if ($checksumLines.Count -eq 0) { throw 'SHA256SUMS is empty.' }
|
||||
foreach ($line in $checksumLines) {
|
||||
$parts = $line -split ' ', 2
|
||||
if ($parts.Count -ne 2) { throw "Malformed checksum line: $line" }
|
||||
$path = Join-Path 'release-audit' $parts[1]
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||
throw "Downloaded release asset is missing: $($parts[1])"
|
||||
}
|
||||
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant()
|
||||
if ($actual -ne $parts[0].ToLowerInvariant()) {
|
||||
throw "Checksum mismatch for downloaded asset: $($parts[1])"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Verify downloaded RC3-to-candidate Windows upgrade and uninstall
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$candidates = @(Get-ChildItem 'release-audit/OpenSquilla-*-win-x64.exe' -File)
|
||||
if ($candidates.Count -ne 1) {
|
||||
throw "Expected exactly one downloaded Windows installer; got $($candidates.Count)."
|
||||
}
|
||||
.github/scripts/verify-release-windows-upgrade.ps1 `
|
||||
-CandidateInstaller $candidates[0].FullName `
|
||||
-Label downloaded-asset
|
||||
Reference in New Issue
Block a user