Skip to content

Merge pull request #1 from AsieduDevelopmentHub/feature/reliability-s… #18

Merge pull request #1 from AsieduDevelopmentHub/feature/reliability-s…

Merge pull request #1 from AsieduDevelopmentHub/feature/reliability-s… #18

Workflow file for this run

name: Build Release Artifacts
on:
workflow_dispatch:
push:
tags:
- "v*"
permissions:
contents: write
env:
# RELEASE_VERSION: git ref label (branch name or tag without v). ARTIFACT_VERSION: safe semver for filenames.
RELEASE_VERSION: ""
ARTIFACT_VERSION: ""
# Starting port written into engine-config.json in CI (engine may increment).
ENGINE_PORT: "8740"
jobs:
windows-installer-x64:
name: Windows x64 installer
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version from tag
shell: pwsh
run: |
$ref = "${env:GITHUB_REF_NAME}"
$run = "${env:GITHUB_RUN_NUMBER}"
if ($ref -like "v*") {
$ver = $ref.Substring(1)
$artifactVer = $ver
} else {
$ver = $ref
# Branch/manual runs: avoid vmain in installer names; keep unique per workflow run.
$artifactVer = "0.0.0.$run"
}
"RELEASE_VERSION=$ver" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"ARTIFACT_VERSION=$artifactVer" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "Release version: $ver (artifact files: v$artifactVer)"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Cache pip
uses: actions/cache@v4
with:
path: |
~\AppData\Local\pip\Cache
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Inject version into project files
shell: pwsh
run: |
$ver = "${env:RELEASE_VERSION}"
# Flutter pubspec / Inno require semver x.y.z; branch names (e.g. main) are invalid.
$pubVer = if ($ver -match '^(\d+\.\d+\.\d+)') { $Matches[1] } else { "0.0.0" }
# Flutter pubspec version (build number from CI run)
(Get-Content "dashboard/pubspec.yaml") `
-replace '^version:\s*.+$', "version: $pubVer+${env:GITHUB_RUN_NUMBER}" `
| Set-Content "dashboard/pubspec.yaml"
# Engine version metadata
(Get-Content "engine/__init__.py") `
-replace '__version__\s*=\s*\"[^\"]+\"', "__version__ = `"$ver`"" `
| Set-Content "engine/__init__.py"
(Get-Content "engine/api/server.py") `
-replace 'version=\"[^\"]+\"', "version=`"$ver`"" `
| Set-Content "engine/api/server.py"
# Inno Setup version + output name (use ARTIFACT_VERSION so branch runs are not vmain)
$artifactVer = "${env:ARTIFACT_VERSION}"
(Get-Content "installer/sentracore.iss") `
-replace '^AppVersion=.*$', "AppVersion=$pubVer" `
-replace '^OutputBaseFilename=.*$', "OutputBaseFilename=SentraCore_Setup_v$artifactVer" `
| Set-Content "installer/sentracore.iss"
- name: Create venv + install engine deps
shell: pwsh
run: |
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe -m pip install pyinstaller
- name: Build engine (PyInstaller)
shell: pwsh
run: |
.\.venv\Scripts\python.exe -m PyInstaller --name "SentraCoreEngine" `
--noconsole `
--onefile `
--hidden-import "uvicorn.logging" `
--hidden-import "uvicorn.loops" `
--hidden-import "uvicorn.loops.auto" `
--hidden-import "uvicorn.protocols" `
--hidden-import "uvicorn.protocols.http" `
--hidden-import "uvicorn.protocols.http.auto" `
--hidden-import "uvicorn.protocols.websockets" `
--hidden-import "uvicorn.protocols.websockets.auto" `
--hidden-import "engine.api.server" `
--clean `
engine/main.py
if (!(Test-Path "dist\\SentraCoreEngine.exe")) { throw "Engine exe not found" }
- name: Validate engine runs (health check)
shell: pwsh
run: |
$distDir = Join-Path (Get-Location) "dist"
if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null }
$cfgPath = Join-Path $distDir "engine-config.json"
# Kill anything listening on the start port (best-effort).
$startPort = [int]${env:ENGINE_PORT}
$pids = (Get-NetTCPConnection -State Listen -LocalPort $startPort -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique)
foreach ($procId in $pids) { try { Stop-Process -Id $procId -Force } catch {} }
# Write engine-config.json next to engine (single source of truth).
$cfg = @{
host = "127.0.0.1"
port = $startPort
bind_host = "127.0.0.1"
status = "stopped"
last_error = ""
pid = 0
} | ConvertTo-Json
$tmp = "$cfgPath.tmp"
$cfg | Out-File -FilePath $tmp -Encoding utf8
Move-Item -Force $tmp $cfgPath
$cfgFull = [System.IO.Path]::GetFullPath($cfgPath)
$p = Start-Process -FilePath ".\\dist\\SentraCoreEngine.exe" -PassThru -WindowStyle Hidden -Environment @{ "SENTRACORE_ENGINE_CONFIG" = $cfgFull }
try {
$ok = $false
for ($i=0; $i -lt 40; $i++) {
try {
$cfgDisk = $null
try { $cfgDisk = Get-Content "$cfgFull" -Raw | ConvertFrom-Json } catch {}
# Never use $host — it shadows PowerShell's automatic $Host and breaks this loop.
$connectHost = if ($cfgDisk -and $cfgDisk.host) { $cfgDisk.host } else { "127.0.0.1" }
$port = if ($cfgDisk -and $cfgDisk.port) { [int]$cfgDisk.port } else { $startPort }
$r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 "http://${connectHost}:${port}/api/v1/health"
if ($r.StatusCode -eq 200) { $ok = $true; break }
} catch {}
Start-Sleep -Milliseconds 500
}
if (-not $ok) { throw "Engine health check failed" }
} finally {
try { Stop-Process -Id $p.Id -Force } catch {}
try { Stop-Process -Name "SentraCoreEngine" -Force } catch {}
}
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Build dashboard (Flutter Windows x64)
shell: pwsh
working-directory: dashboard
run: |
flutter --version
flutter pub get
flutter build windows --release
if (!(Test-Path "build\\windows\\x64\\runner\\Release\\sentracore_dashboard.exe")) { throw "Dashboard exe not found" }
- name: Ship engine-config.json next to Windows binaries (installer payload)
shell: pwsh
run: |
$cfg = "dashboard\assets\engine-config.json"
if (!(Test-Path $cfg)) { throw "Missing $cfg" }
$releaseDir = "dashboard\build\windows\x64\runner\Release"
Copy-Item $cfg (Join-Path $releaseDir "engine-config.json") -Force
Copy-Item $cfg "dist\engine-config.json" -Force
Write-Host "engine-config.json -> Release + dist"
- name: Code sign engine + dashboard (optional)
shell: pwsh
env:
WINDOWS_CODESIGN_PFX_BASE64: ${{ secrets.WINDOWS_CODESIGN_PFX_BASE64 }}
WINDOWS_CODESIGN_PFX_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PFX_PASSWORD }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PFX_BASE64)) {
Write-Host "Skipping Authenticode signing (set repo secret WINDOWS_CODESIGN_PFX_BASE64 + WINDOWS_CODESIGN_PFX_PASSWORD; see docs/architecture/building.md)."
exit 0
}
$pfxPath = Join-Path $env:RUNNER_TEMP "sentracore-codesign.pfx"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_CODESIGN_PFX_BASE64))
& ".\scripts\sign-windows-artifacts.ps1" -PfxPath $pfxPath -RepoRoot (Get-Location).Path
Remove-Item -LiteralPath $pfxPath -Force
- name: Install Inno Setup
shell: pwsh
run: |
choco install innosetup -y --no-progress
- name: Build installer (ISCC)
shell: pwsh
run: |
$iscc = "${env:ProgramFiles(x86)}\\Inno Setup 6\\ISCC.exe"
if (!(Test-Path $iscc)) { throw "ISCC not found at $iscc" }
& $iscc "installer\\sentracore.iss"
Get-ChildItem "dist" -Filter "SentraCore_Setup_v*.exe" | Select-Object -First 1 | ForEach-Object { $_.FullName }
- name: Code sign installer (optional)
shell: pwsh
env:
WINDOWS_CODESIGN_PFX_BASE64: ${{ secrets.WINDOWS_CODESIGN_PFX_BASE64 }}
WINDOWS_CODESIGN_PFX_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PFX_PASSWORD }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CODESIGN_PFX_BASE64)) {
Write-Host "Skipping installer signing (same secrets as engine/dashboard step)."
exit 0
}
$pfxPath = Join-Path $env:RUNNER_TEMP "sentracore-codesign-installer.pfx"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_CODESIGN_PFX_BASE64))
& ".\scripts\sign-windows-artifacts.ps1" -PfxPath $pfxPath -RepoRoot (Get-Location).Path -InstallerOnly
Remove-Item -LiteralPath $pfxPath -Force
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: SentraCore_Windows_x64_installer
path: dist/SentraCore_Setup_v*.exe
macos-app:
name: macOS .app + engine
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Derive version from tag
run: |
REF="${GITHUB_REF_NAME}"
RUN="${GITHUB_RUN_NUMBER:-0}"
if [[ "$REF" == v* ]]; then
VER="${REF:1}"
ART="$VER"
else
VER="$REF"
ART="0.0.0.${RUN}"
fi
echo "RELEASE_VERSION=$VER" >> "$GITHUB_ENV"
echo "ARTIFACT_VERSION=$ART" >> "$GITHUB_ENV"
echo "Release version: $VER (artifact files: v$ART)"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Cache pip
uses: actions/cache@v4
with:
path: |
~/Library/Caches/pip
~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Inject version into project files
run: |
python - <<'PY'
import os, re, pathlib
ver = os.environ["RELEASE_VERSION"]
run = os.environ.get("GITHUB_RUN_NUMBER", "1")
m = re.match(r"^(\d+\.\d+\.\d+)", ver.strip())
pub_ver = m.group(1) if m else "0.0.0"
p = pathlib.Path("dashboard/pubspec.yaml")
p.write_text(re.sub(r"^version:\s*.+$", f"version: {pub_ver}+{run}", p.read_text(), flags=re.M))
init = pathlib.Path("engine/__init__.py")
init.write_text(re.sub(r'__version__\s*=\s*\"[^\"]+\"', f'__version__ = \"{ver}\"', init.read_text()))
srv = pathlib.Path("engine/api/server.py")
srv.write_text(re.sub(r'version=\"[^\"]+\"', f'version=\"{ver}\"', srv.read_text()))
PY
- name: Install engine deps + PyInstaller
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install pyinstaller
- name: Build engine (PyInstaller)
run: |
python -m PyInstaller --name "SentraCoreEngine" \
--onefile \
--windowed \
--hidden-import "uvicorn.logging" \
--hidden-import "uvicorn.loops" \
--hidden-import "uvicorn.loops.auto" \
--hidden-import "uvicorn.protocols" \
--hidden-import "uvicorn.protocols.http" \
--hidden-import "uvicorn.protocols.http.auto" \
--hidden-import "uvicorn.protocols.websockets" \
--hidden-import "uvicorn.protocols.websockets.auto" \
--hidden-import "engine.api.server" \
--clean \
engine/main.py
test -f dist/SentraCoreEngine
- name: Validate engine runs (health check)
run: |
chmod +x dist/SentraCoreEngine
START_PORT="${ENGINE_PORT:-8740}"
# Kill anything on start port (best-effort).
lsof -ti "tcp:${START_PORT}" | xargs kill -9 2>/dev/null || true
# Write engine-config.json next to engine.
cat > dist/engine-config.json <<EOF
{
"host": "127.0.0.1",
"port": ${START_PORT},
"bind_host": "127.0.0.1",
"status": "stopped",
"last_error": "",
"pid": 0
}
EOF
SENTRACORE_ENGINE_CONFIG="$(pwd)/dist/engine-config.json" ./dist/SentraCoreEngine &
PID=$!
OK=0
for i in {1..40}; do
PORT="$(python - <<'PY'
import json, pathlib
p = pathlib.Path("dist/engine-config.json")
try:
j = json.loads(p.read_text())
print(int(j.get("port", 0)) or 0)
except Exception:
print(0)
PY
)"
if [ -z "$PORT" ] || [ "$PORT" = "0" ]; then PORT="$START_PORT"; fi
if curl -fsS --max-time 2 "http://127.0.0.1:${PORT}/api/v1/health" >/dev/null; then
OK=1
break
fi
sleep 0.5
done
kill $PID || true
pkill -f SentraCoreEngine || true
if [ "$OK" -ne 1 ]; then
echo "Engine health check failed"
exit 1
fi
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Build dashboard (Flutter macOS)
working-directory: dashboard
run: |
flutter pub get
flutter build macos --release
- name: Bundle engine into .app + sign (ad-hoc)
run: |
set -euo pipefail
RELEASE_DIR="dashboard/build/macos/Build/Products/Release"
if [ ! -d "$RELEASE_DIR" ]; then
echo "Missing Release dir: $RELEASE_DIR"
exit 1
fi
APP="$(find "$RELEASE_DIR" -maxdepth 1 -name "*.app" -print -quit)"
if [ -z "${APP}" ] || [ ! -d "$APP" ]; then
echo "No .app bundle under $RELEASE_DIR — contents:"
ls -la "$RELEASE_DIR" || true
exit 1
fi
echo "Using app bundle: $APP"
chmod +x dist/SentraCoreEngine
codesign --force --sign - dist/SentraCoreEngine
cp dist/SentraCoreEngine "$APP/Contents/MacOS/SentraCoreEngine"
chmod +x "$APP/Contents/MacOS/SentraCoreEngine"
cp dashboard/assets/engine-config.json "$APP/Contents/MacOS/engine-config.json"
codesign --force --deep --sign - "$APP"
- name: Package (zip)
run: |
set -euo pipefail
RELEASE_DIR="dashboard/build/macos/Build/Products/Release"
APP="$(find "$RELEASE_DIR" -maxdepth 1 -name "*.app" -print -quit)"
if [ -z "${APP}" ] || [ ! -d "$APP" ]; then
echo "No .app bundle under $RELEASE_DIR"
ls -la "$RELEASE_DIR" || true
exit 1
fi
zip -r "SentraCore_macOS_${ARTIFACT_VERSION}.zip" "$APP"
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
with:
name: SentraCore_macOS_app
path: SentraCore_macOS_*.zip
linux-bundle:
name: Linux bundle + engine
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Derive version from tag
run: |
REF="${GITHUB_REF_NAME}"
RUN="${GITHUB_RUN_NUMBER:-0}"
if [[ "$REF" == v* ]]; then
VER="${REF:1}"
ART="$VER"
else
VER="$REF"
ART="0.0.0.${RUN}"
fi
echo "RELEASE_VERSION=$VER" >> "$GITHUB_ENV"
echo "ARTIFACT_VERSION=$ART" >> "$GITHUB_ENV"
echo "Release version: $VER (artifact files: v$ART)"
- name: Install Linux desktop build deps
run: |
sudo apt-get update
sudo apt-get install -y \
clang cmake ninja-build pkg-config \
libgtk-3-dev liblzma-dev \
libfuse2 curl
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
- name: Inject version into project files
run: |
python - <<'PY'
import os, re, pathlib
ver = os.environ["RELEASE_VERSION"]
run = os.environ.get("GITHUB_RUN_NUMBER", "1")
m = re.match(r"^(\d+\.\d+\.\d+)", ver.strip())
pub_ver = m.group(1) if m else "0.0.0"
p = pathlib.Path("dashboard/pubspec.yaml")
p.write_text(re.sub(r"^version:\s*.+$", f"version: {pub_ver}+{run}", p.read_text(), flags=re.M))
init = pathlib.Path("engine/__init__.py")
init.write_text(re.sub(r'__version__\s*=\s*\"[^\"]+\"', f'__version__ = \"{ver}\"', init.read_text()))
srv = pathlib.Path("engine/api/server.py")
srv.write_text(re.sub(r'version=\"[^\"]+\"', f'version=\"{ver}\"', srv.read_text()))
PY
- name: Install engine deps + PyInstaller
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install pyinstaller
- name: Build engine (PyInstaller)
run: |
python -m PyInstaller --name "SentraCoreEngine" \
--onefile \
--hidden-import "uvicorn.logging" \
--hidden-import "uvicorn.loops" \
--hidden-import "uvicorn.loops.auto" \
--hidden-import "uvicorn.protocols" \
--hidden-import "uvicorn.protocols.http" \
--hidden-import "uvicorn.protocols.http.auto" \
--hidden-import "uvicorn.protocols.websockets" \
--hidden-import "uvicorn.protocols.websockets.auto" \
--hidden-import "engine.api.server" \
--clean \
engine/main.py
test -f dist/SentraCoreEngine
- name: Validate engine runs (health check)
run: |
chmod +x dist/SentraCoreEngine
START_PORT="${ENGINE_PORT:-8740}"
fuser -k "${START_PORT}/tcp" 2>/dev/null || true
cat > dist/engine-config.json <<EOF
{
"host": "127.0.0.1",
"port": ${START_PORT},
"bind_host": "0.0.0.0",
"status": "stopped",
"last_error": "",
"pid": 0
}
EOF
SENTRACORE_ENGINE_CONFIG="$(pwd)/dist/engine-config.json" ./dist/SentraCoreEngine &
PID=$!
OK=0
for i in {1..40}; do
PORT="$(python - <<'PY'
import json, pathlib
p = pathlib.Path("dist/engine-config.json")
try:
j = json.loads(p.read_text())
print(int(j.get("port", 0)) or 0)
except Exception:
print(0)
PY
)"
if [ -z "$PORT" ] || [ "$PORT" = "0" ]; then PORT="$START_PORT"; fi
if curl -fsS --max-time 2 "http://127.0.0.1:${PORT}/api/v1/health" >/dev/null; then
OK=1
break
fi
sleep 0.5
done
kill $PID || true
pkill -f SentraCoreEngine || true
if [ "$OK" -ne 1 ]; then
echo "Engine health check failed"
exit 1
fi
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: Build dashboard (Flutter Linux)
working-directory: dashboard
run: |
flutter pub get
flutter build linux --release
- name: Package (AppImage)
run: |
# Create an AppDir with Flutter bundle + engine
rm -rf SentraCore.AppDir
mkdir -p SentraCore.AppDir/usr/bin
cp -R dashboard/build/linux/x64/release/bundle/* SentraCore.AppDir/usr/bin/
cp dist/SentraCoreEngine SentraCore.AppDir/usr/bin/SentraCoreEngine
chmod +x SentraCore.AppDir/usr/bin/SentraCoreEngine
cp dashboard/assets/engine-config.json SentraCore.AppDir/usr/bin/engine-config.json
# AppRun launcher
cat > SentraCore.AppDir/AppRun <<'EOF'
#!/bin/sh
set -e
HERE="$(dirname "$(readlink -f "$0")")"
cd "$HERE/usr/bin"
# Create engine-config.json (single source of truth) next to binaries.
if [ ! -f "./engine-config.json" ]; then
cat > "./engine-config.json" <<'JSON'
{
"host": "127.0.0.1",
"port": 8740,
"bind_host": "0.0.0.0",
"status": "stopped",
"last_error": "",
"pid": 0
}
JSON
fi
# Start engine in background (best-effort) and stop it when UI exits.
SENTRACORE_ENGINE_CONFIG="$HERE/usr/bin/engine-config.json" ./SentraCoreEngine >/dev/null 2>&1 &
ENGINE_PID=$!
trap "kill $ENGINE_PID >/dev/null 2>&1 || true; pkill -f SentraCoreEngine >/dev/null 2>&1 || true" EXIT
# Wait until engine responds (avoid UI starting before backend).
OK=0
for i in $(seq 1 40); do
PORT="$(sed -n 's/.*\"port\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' ./engine-config.json | head -n 1)"
if [ -z "$PORT" ]; then PORT="8740"; fi
if curl -fsS --max-time 2 "http://127.0.0.1:${PORT}/api/v1/health" >/dev/null; then
OK=1
break
fi
sleep 0.5
done
if [ "$OK" -ne 1 ]; then
echo "Engine health check failed"
exit 1
fi
exec ./sentracore_dashboard "$@"
EOF
chmod +x SentraCore.AppDir/AppRun
# Desktop entry
cat > SentraCore.AppDir/sentracore.desktop <<'EOF'
[Desktop Entry]
Type=Application
Name=SentraCore
Exec=sentracore_dashboard
Icon=sentracore
Categories=Utility;
EOF
# Minimal icon (1x1 PNG) to satisfy appimagetool
printf 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7cS2kAAAAASUVORK5CYII=' | base64 -d > SentraCore.AppDir/sentracore.png
# Download appimagetool and build AppImage
curl -L -o appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x appimagetool
./appimagetool SentraCore.AppDir SentraCore_linux.AppImage
test -f SentraCore_linux.AppImage
- name: Rename AppImage with version
run: |
mv SentraCore_linux.AppImage "SentraCore_linux_${ARTIFACT_VERSION}.AppImage"
- name: Upload Linux artifact
uses: actions/upload-artifact@v4
with:
name: SentraCore_Linux_bundle
path: SentraCore_linux_*.AppImage
github-release:
name: Publish GitHub Release
needs: [windows-installer-x64, macos-app, linux-bundle]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: List artifacts
run: ls -R artifacts
- name: Create GitHub Release (attach assets)
uses: softprops/action-gh-release@v2
with:
name: SentraCore ${{ github.ref_name }}
body: |
## Highlights
- Updated dashboard UI with new home page and settings page.
- Added new settings page to configure engine settings.
- Added new home page to display engine status and settings.
- Website: modern GitHub Pages landing site with direct-download buttons (via GitHub Releases API).
## Downloads
Grab the correct asset for your OS from the files attached to this release:
- Windows: `SentraCore_Setup_v*.exe`
- macOS: `SentraCore_macOS_*.zip`
- Linux: `SentraCore_linux_*.AppImage`
## Notes
- If your system blocks the Releases API, the website buttons fall back to the Releases page.
files: |
artifacts/**/SentraCore_Setup_v*.exe
artifacts/**/SentraCore_macOS_*.zip
artifacts/**/SentraCore_linux_*.AppImage