This update introduces a new mechanism for handling multiple publish targets when uploading artifacts. It refines the configuration by parsing legacy targets from environment variables and allows for dynamic bucket and directory management during the publishing process. - Added support for multiple publish targets from environment variables - Refactored artifact upload logic to improve maintainability - Updated tauri configuration to include new endpoint for latest.json
410 lines
13 KiB
Python
410 lines
13 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, NamedTuple, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
from minio import Minio
|
|
from minio.error import S3Error
|
|
|
|
|
|
# cicd_tool lives inside the Gitty 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."""
|
|
|
|
|
|
class PublishTarget(NamedTuple):
|
|
bucket_name: str
|
|
app_dir: str
|
|
|
|
|
|
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] = []
|
|
bundle_dirs = {
|
|
ARTIFACT_ROOT / "msi": (".msi", ".sig"),
|
|
ARTIFACT_ROOT / "nsis": (".exe", ".sig"),
|
|
ARTIFACT_ROOT / "appimage": (".appimage", ".sig"),
|
|
ARTIFACT_ROOT / "deb": (".deb", ".sig"),
|
|
ARTIFACT_ROOT / "rpm": (".rpm", ".sig"),
|
|
ARTIFACT_ROOT / "app": (".dmg", ".zip", ".sig"),
|
|
}
|
|
for directory, suffixes in bundle_dirs.items():
|
|
if directory.exists():
|
|
artifacts.extend(
|
|
p
|
|
for p in directory.iterdir()
|
|
if p.is_file() and p.suffix.lower() in suffixes
|
|
)
|
|
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 _parse_publish_targets(primary_bucket: str, primary_app_dir: str) -> list[PublishTarget]:
|
|
targets = [PublishTarget(primary_bucket, primary_app_dir)]
|
|
raw_legacy_targets = os.environ.get("LEGACY_UPDATE_TARGETS", "gitlite:gitlite")
|
|
|
|
for raw_target in raw_legacy_targets.split(","):
|
|
value = raw_target.strip()
|
|
if not value:
|
|
continue
|
|
|
|
if ":" in value:
|
|
bucket_name, app_dir = value.split(":", 1)
|
|
else:
|
|
bucket_name = value
|
|
app_dir = value
|
|
|
|
target = PublishTarget(bucket_name.strip().lower(), app_dir.strip("/"))
|
|
if target.bucket_name and target not in targets:
|
|
targets.append(target)
|
|
|
|
return targets
|
|
|
|
|
|
def _publish_target(
|
|
client: Minio,
|
|
target: PublishTarget,
|
|
version: str,
|
|
platform: str,
|
|
artifacts: Iterable[Path],
|
|
primary_artifact: Path,
|
|
signature: str,
|
|
artifact_base_url: str,
|
|
) -> None:
|
|
_ensure_bucket(client, target.bucket_name)
|
|
existing_latest_json = _read_existing_latest_json(client, target.bucket_name)
|
|
latest_payload = _build_latest_payload(
|
|
version=version,
|
|
platform=platform,
|
|
primary_artifact=primary_artifact,
|
|
signature=signature,
|
|
artifact_base_url=artifact_base_url,
|
|
app_dir=target.app_dir,
|
|
existing_payload=existing_latest_json,
|
|
)
|
|
latest_json = json.dumps(latest_payload, separators=(",", ":"))
|
|
|
|
for artifact in artifacts:
|
|
object_segments = [part for part in (version, artifact.name) if part]
|
|
object_name = "/".join(object_segments)
|
|
client.fput_object(target.bucket_name, object_name, str(artifact))
|
|
print(f"Uploaded {artifact.name} -> s3://{target.bucket_name}/{object_name}")
|
|
|
|
latest_bytes = latest_json.encode("utf-8")
|
|
client.put_object(
|
|
bucket_name=target.bucket_name,
|
|
object_name="latest.json",
|
|
data=io.BytesIO(latest_bytes),
|
|
length=len(latest_bytes),
|
|
content_type="application/json",
|
|
)
|
|
print(
|
|
f"Uploaded latest.json for {platform} -> "
|
|
f"s3://{target.bucket_name}/latest.json"
|
|
)
|
|
|
|
|
|
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. "Gitty" -> "gitty").
|
|
bucket_name = (os.environ.get("S3_BUCKET") or "gitty").lower()
|
|
bucket_prefix = (app_dir or os.environ.get("APP_DIR") or "gitty").strip("/")
|
|
publish_targets = _parse_publish_targets(bucket_name, bucket_prefix)
|
|
|
|
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()
|
|
|
|
local_dist_dir = PROJECT_DIR / "dist"
|
|
local_dist_dir.mkdir(parents=True, exist_ok=True)
|
|
latest_json_path = local_dist_dir / "latest.json"
|
|
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,
|
|
)
|
|
latest_json = json.dumps(latest_payload, separators=(",", ":"))
|
|
latest_json_path.write_text(latest_json + "\n", encoding="utf-8")
|
|
|
|
for target in publish_targets:
|
|
_publish_target(
|
|
client=client,
|
|
target=target,
|
|
version=resolved_version,
|
|
platform=platform,
|
|
artifacts=artifacts,
|
|
primary_artifact=primary,
|
|
signature=signature,
|
|
artifact_base_url=artifact_base_url,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(
|
|
description="Upload Gitty 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 gitty).",
|
|
)
|
|
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
|