876 lines
30 KiB
YAML
876 lines
30 KiB
YAML
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
|