The CI workflow now builds a single bundle per run, adds Linux AppImage output, and updates the Tauri version using a Node script. The publishing tool is extended to recognize Linux artifacts, select a primary artifact based on the target platform, and merge platform entries into an existing latest.json when present. - Limit matrix parallelism and pass bundle targets to Tauri - Update updater platform handling and latest.json merging - Add Linux Tauri config for AppImage bundling
346 lines
11 KiB
Python
346 lines
11 KiB
Python
import argparse
|
|
import io
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, List, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
from minio import Minio
|
|
from minio.error import S3Error
|
|
|
|
|
|
# cicd_tool lives inside the GitLite repo, so its parent dir is the app root.
|
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
|
ARTIFACT_ROOT = PROJECT_DIR / "src-tauri" / "target" / "release" / "bundle"
|
|
|
|
|
|
class ConfigurationError(RuntimeError):
|
|
"""Raised when required configuration values are missing or invalid."""
|
|
|
|
|
|
def _resolve_version(version_arg: Optional[str]) -> str:
|
|
"""Resolve the version to publish, falling back to tauri.conf.json."""
|
|
if version_arg and version_arg.lower() not in {"auto"}:
|
|
return version_arg
|
|
|
|
config_path = PROJECT_DIR / "src-tauri" / "tauri.conf.json"
|
|
if not config_path.exists():
|
|
raise ConfigurationError(f"Cannot locate {config_path}")
|
|
|
|
try:
|
|
tauri_conf = json.loads(config_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ConfigurationError(f"Invalid JSON in {config_path}") from exc
|
|
|
|
version = tauri_conf.get("version")
|
|
if not version:
|
|
raise ConfigurationError("Version not found in tauri.conf.json")
|
|
|
|
return str(version)
|
|
|
|
|
|
def _normalise_endpoint(raw_endpoint: str) -> tuple[str, bool]:
|
|
"""Return (endpoint, secure) suitable for the MinIO client."""
|
|
if not raw_endpoint:
|
|
raise ConfigurationError("MINIO_ENDPOINT is required")
|
|
|
|
if "://" not in raw_endpoint:
|
|
return raw_endpoint, not raw_endpoint.startswith("http://")
|
|
|
|
parsed = urlparse(raw_endpoint)
|
|
if not parsed.netloc:
|
|
raise ConfigurationError(f"MINIO_ENDPOINT is invalid: {raw_endpoint}")
|
|
secure = parsed.scheme != "http"
|
|
return parsed.netloc, secure
|
|
|
|
|
|
def _create_minio_client() -> Minio:
|
|
endpoint_raw = os.environ.get("MINIO_ENDPOINT", "")
|
|
endpoint, secure = _normalise_endpoint(endpoint_raw)
|
|
|
|
access_key = os.environ.get("S3_ACCESS_KEY") or os.environ.get("AWS_ACCESS_KEY_ID")
|
|
secret_key = os.environ.get("S3_SECRET_KEY") or os.environ.get(
|
|
"AWS_SECRET_ACCESS_KEY"
|
|
)
|
|
if not access_key or not secret_key:
|
|
raise ConfigurationError(
|
|
"Missing S3 credentials. Provide S3_ACCESS_KEY/S3_SECRET_KEY or "
|
|
"AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY."
|
|
)
|
|
|
|
region = os.environ.get("S3_REGION") or os.environ.get("AWS_REGION")
|
|
|
|
return Minio(
|
|
endpoint=endpoint,
|
|
access_key=access_key,
|
|
secret_key=secret_key,
|
|
secure=secure,
|
|
region=region,
|
|
)
|
|
|
|
|
|
def _public_read_policy(bucket_name: str) -> str:
|
|
"""Anonymous read-only policy so the Tauri updater can fetch artifacts."""
|
|
return json.dumps(
|
|
{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Principal": {"AWS": ["*"]},
|
|
"Action": ["s3:GetObject"],
|
|
"Resource": [f"arn:aws:s3:::{bucket_name}/*"],
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
def _ensure_bucket(client: Minio, bucket_name: str) -> None:
|
|
if not bucket_name:
|
|
raise ConfigurationError("S3_BUCKET is required")
|
|
|
|
exists = client.bucket_exists(bucket_name)
|
|
if not exists:
|
|
client.make_bucket(bucket_name)
|
|
print(f"Created bucket '{bucket_name}'.")
|
|
|
|
# Always (re)apply public read so a freshly created -- or previously private --
|
|
# bucket can be read anonymously by the updater.
|
|
client.set_bucket_policy(bucket_name, _public_read_policy(bucket_name))
|
|
print(f"Ensured public read policy on bucket '{bucket_name}'.")
|
|
|
|
|
|
def _collect_artifacts() -> List[Path]:
|
|
artifacts: List[Path] = []
|
|
for directory in (
|
|
ARTIFACT_ROOT / "msi",
|
|
ARTIFACT_ROOT / "nsis",
|
|
ARTIFACT_ROOT / "appimage",
|
|
ARTIFACT_ROOT / "deb",
|
|
ARTIFACT_ROOT / "rpm",
|
|
ARTIFACT_ROOT / "app",
|
|
):
|
|
if directory.exists():
|
|
artifacts.extend(p for p in directory.rglob("*") if p.is_file())
|
|
return artifacts
|
|
|
|
|
|
def _select_primary_artifact(
|
|
artifacts: Iterable[Path], platform: Optional[str] = None
|
|
) -> Optional[Path]:
|
|
artifact_list = list(artifacts)
|
|
priorities = {
|
|
"windows-x86_64": (".exe", ".msi"),
|
|
"linux-x86_64": (".appimage", ".deb", ".rpm"),
|
|
"darwin-x86_64": (".dmg", ".zip"),
|
|
"darwin-aarch64": (".dmg", ".zip"),
|
|
}
|
|
priority = priorities.get(platform or "", ())
|
|
priority += (".exe", ".msi", ".appimage", ".deb", ".rpm", ".zip", ".dmg")
|
|
for ext in priority:
|
|
for artifact in artifact_list:
|
|
if artifact.suffix.lower() == ext:
|
|
return artifact
|
|
return next(
|
|
(artifact for artifact in artifact_list if artifact.suffix != ".sig"), None
|
|
)
|
|
|
|
|
|
def _infer_platform(artifact: Path) -> str:
|
|
suffix = artifact.suffix.lower()
|
|
if suffix in {".exe", ".msi"}:
|
|
return "windows-x86_64"
|
|
if suffix in {".appimage", ".deb", ".rpm"}:
|
|
return "linux-x86_64"
|
|
if suffix in {".dmg", ".zip"}:
|
|
return "darwin-x86_64"
|
|
raise ConfigurationError(
|
|
f"Cannot infer updater platform from artifact: {artifact.name}. "
|
|
"Set PUBLISH_PLATFORM explicitly."
|
|
)
|
|
|
|
|
|
def _read_signature(artifact: Path) -> str:
|
|
candidates = [
|
|
artifact.with_suffix(artifact.suffix + ".sig"),
|
|
artifact.with_name(f"{artifact.stem}.sig"),
|
|
]
|
|
for candidate in candidates:
|
|
if candidate.exists():
|
|
return candidate.read_text(encoding="utf-8").strip()
|
|
raise FileNotFoundError(f"Signature not found for {artifact.name}")
|
|
|
|
|
|
def _build_artifact_url(
|
|
version: str,
|
|
primary_artifact: Path,
|
|
artifact_base_url: str,
|
|
app_dir: str,
|
|
) -> str:
|
|
base_url = artifact_base_url.rstrip("/")
|
|
path_parts = [app_dir.strip("/"), version, primary_artifact.name]
|
|
return "/".join([base_url, *filter(None, path_parts)])
|
|
|
|
|
|
def _read_existing_latest_json(
|
|
client: Minio, bucket_name: str
|
|
) -> Optional[dict[str, Any]]:
|
|
try:
|
|
response = client.get_object(bucket_name, "latest.json")
|
|
except S3Error as exc:
|
|
if exc.code in {"NoSuchKey", "NoSuchObject"}:
|
|
return None
|
|
raise
|
|
|
|
try:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise ConfigurationError("Existing latest.json is invalid JSON") from exc
|
|
finally:
|
|
response.close()
|
|
response.release_conn()
|
|
|
|
if not isinstance(payload, dict):
|
|
raise ConfigurationError("Existing latest.json must be a JSON object")
|
|
return payload
|
|
|
|
|
|
def _build_latest_payload(
|
|
version: str,
|
|
platform: str,
|
|
primary_artifact: Path,
|
|
signature: str,
|
|
artifact_base_url: str,
|
|
app_dir: str,
|
|
existing_payload: Optional[dict[str, Any]] = None,
|
|
) -> dict[str, Any]:
|
|
artifact_url = _build_artifact_url(
|
|
version=version,
|
|
primary_artifact=primary_artifact,
|
|
artifact_base_url=artifact_base_url,
|
|
app_dir=app_dir,
|
|
)
|
|
|
|
pub_date = (
|
|
datetime.now(timezone.utc)
|
|
.replace(microsecond=0)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
)
|
|
|
|
platforms: dict[str, Any] = {}
|
|
existing_notes = None
|
|
if existing_payload and existing_payload.get("version") == version:
|
|
existing_platforms = existing_payload.get("platforms")
|
|
if isinstance(existing_platforms, dict):
|
|
platforms.update(existing_platforms)
|
|
existing_notes = existing_payload.get("notes")
|
|
|
|
platforms[platform] = {
|
|
"signature": signature,
|
|
"url": artifact_url,
|
|
}
|
|
|
|
return {
|
|
"version": version,
|
|
"pub_date": pub_date,
|
|
"notes": os.environ.get("RELEASE_NOTES")
|
|
or existing_notes
|
|
or "Automated release",
|
|
"platforms": platforms,
|
|
}
|
|
|
|
|
|
def main(version: Optional[str] = None, app_dir: Optional[str] = None) -> None:
|
|
resolved_version = _resolve_version(version)
|
|
artifact_base_url = os.environ.get("ARTIFACT_BASE_URL", "")
|
|
if not artifact_base_url:
|
|
raise ConfigurationError("ARTIFACT_BASE_URL is required")
|
|
|
|
# S3/MinIO bucket names must be lowercase, so normalise regardless of how the
|
|
# S3_BUCKET variable is cased (e.g. "GitLite" -> "gitlite").
|
|
bucket_name = (os.environ.get("S3_BUCKET") or "gitlite").lower()
|
|
bucket_prefix = (app_dir or os.environ.get("APP_DIR") or "gitlite").strip("/")
|
|
|
|
artifacts = _collect_artifacts()
|
|
if not artifacts:
|
|
raise FileNotFoundError(
|
|
"No release artifacts found in src-tauri/target/release/bundle."
|
|
)
|
|
|
|
publish_platform = os.environ.get("PUBLISH_PLATFORM") or os.environ.get(
|
|
"UPDATER_PLATFORM"
|
|
)
|
|
primary = _select_primary_artifact(artifacts, publish_platform)
|
|
if not primary:
|
|
raise FileNotFoundError("Unable to determine primary artifact to publish.")
|
|
|
|
platform = publish_platform or _infer_platform(primary)
|
|
signature = _read_signature(primary)
|
|
|
|
client = _create_minio_client()
|
|
_ensure_bucket(client, bucket_name)
|
|
existing_latest_json = _read_existing_latest_json(client, bucket_name)
|
|
|
|
latest_payload = _build_latest_payload(
|
|
version=resolved_version,
|
|
platform=platform,
|
|
primary_artifact=primary,
|
|
signature=signature,
|
|
artifact_base_url=artifact_base_url,
|
|
app_dir=bucket_prefix,
|
|
existing_payload=existing_latest_json,
|
|
)
|
|
latest_json = json.dumps(latest_payload, separators=(",", ":"))
|
|
|
|
local_dist_dir = PROJECT_DIR / "dist"
|
|
local_dist_dir.mkdir(parents=True, exist_ok=True)
|
|
latest_json_path = local_dist_dir / "latest.json"
|
|
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
|
|
|
|
for artifact in artifacts:
|
|
object_segments = [part for part in (resolved_version, artifact.name) if part]
|
|
object_name = "/".join(object_segments)
|
|
client.fput_object(bucket_name, object_name, str(artifact))
|
|
print(f"Uploaded {artifact.name} -> s3://{bucket_name}/{object_name}")
|
|
|
|
latest_object_name = "latest.json"
|
|
latest_bytes = latest_json.encode("utf-8")
|
|
client.put_object(
|
|
bucket_name=bucket_name,
|
|
object_name=latest_object_name,
|
|
data=io.BytesIO(latest_bytes),
|
|
length=len(latest_bytes),
|
|
content_type="application/json",
|
|
)
|
|
print(
|
|
f"Uploaded latest.json for {platform} -> "
|
|
f"s3://{bucket_name}/{latest_object_name}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(
|
|
description="Upload GitLite release artifacts to MinIO."
|
|
)
|
|
parser.add_argument(
|
|
"-v",
|
|
"--version",
|
|
default=None,
|
|
help="Version tag to upload (defaults to reading tauri.conf.json).",
|
|
)
|
|
parser.add_argument(
|
|
"--app-dir",
|
|
default=None,
|
|
help="App directory prefix inside the bucket (defaults to gitlite).",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
main(version=args.version, app_dir=args.app_dir)
|
|
except (ConfigurationError, FileNotFoundError, S3Error) as exc:
|
|
raise SystemExit(f"Upload failed: {exc}") from exc
|