unsloth/.github/scripts/resolve-desktop-release.py
Wasim Yousef Said 67af9aa825
Desktop: unify normal release updater flow (#8298)
* CI: point desktop updater test at fork

* Studio: simplify desktop setup progress UI

* CI: limit updater A/B build to macOS and Windows

* CI: publish macOS and Windows updater test assets

* CI: build Linux and Windows updater test assets

* CI: pin updater test to Windows 2022

* Fix latest-main updater test workflow merge

* Resolve latest-main startup message merge

* Desktop: unify normal release updater flow

* Desktop: validate updater signatures

* Desktop: preserve generated updater signature

* Restore macOS and target the existing v release for PR #8298

Restores the macOS leg that was dropped from the release pipeline: the
macos-latest matrix entry, the .dmg and .app.tar.gz assets, the
darwin-aarch64 platform entries in latest.json, and darwin-aarch64 in the
required families of both release-desktop.yml and publish-desktop-updater.yml.
Without them no macOS bundle is published and macOS clients find no matching
platform in the manifest, so they stop updating entirely.

Targets the v{version} release that already exists instead of creating it.
The tag is cut when main is tagged, before this workflow is dispatched, so
the old "tag already exists" guard failed every run on this repository; it
only passed on a fork where the tags were absent. The guard now requires the
release to exist and refuses only when it already carries desktop assets,
naming the delete-asset commands to recover a failed publish.

Provenance is appended to the release body rather than replacing it, since
that body is the changelog. Windows step conditions follow the restored
windows-latest matrix entry.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address the review on PR #8298

Gate the asset and manifest uploads on the draft input. The target release is
already public, so a validation-only run was publishing unapproved binaries
and latest.json to it.

Move the provenance edit after the uploads and replace any earlier section
instead of skipping it, so a retry that follows a partial upload records the
digests that actually shipped rather than the previous build's.

Reject a prerelease target in both guards. GitHub cannot mark a prerelease
latest, so catching it only at promotion left the bundles already public.

Re-read GitHub latest immediately before promotion. The downgrade check runs
before a build that can take an hour, and promoting past a newer release would
hand every client an older manifest.

Build from the release tag rather than the dispatch ref. The release is
published before the workflow runs and main keeps moving, so the bundles could
come from unrelated source and provenance could record a SHA that is not the
tag's.

Skip the updater validation when the release carries no latest.json. The v
release is published before the bundles land, so the release event fired first
and failed on every release; it now fails closed only when the release cannot
be read. Scope the signature sweep to the desktop bundles, since the release
is shared.

Add the AGPL-3.0 header to the two new files, in the style the repository uses.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep updater discovery on desktop metadata and record the built commit for PR #8298

* Send make_latest as the documented string for PR #8298

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Repair the release-creation tests and the publish-side guard for PR #8298

* Fail closed on promotion, order numbered prereleases and keep the pointer forwardable for PR #8298

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Resolve the newest desktop release lazily and record the updater pointer gap for PR #8298

---------

Co-authored-by: danielhanchen <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-10 01:01:39 -07:00

112 lines
3.7 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Select the newest SemVer-tagged desktop release carrying a required asset."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from typing import Any
SEMVER_TAG = re.compile(
r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
def resolve(releases: Any, asset_suffix: str) -> str | None:
if not isinstance(releases, list):
raise ValueError("release listing must be a JSON array")
candidates: list[tuple[str, str]] = []
for release in releases:
if not isinstance(release, dict):
continue
tag = release.get("tagName")
created_at = release.get("createdAt")
assets = release.get("assets")
if not isinstance(tag, str) or not SEMVER_TAG.fullmatch(tag):
continue
if not isinstance(created_at, str) or not isinstance(assets, list):
continue
if not any(
isinstance(asset, dict)
and isinstance(asset.get("name"), str)
and asset["name"].endswith(asset_suffix)
for asset in assets
):
continue
candidates.append((created_at, tag))
return max(candidates, default = None)[1] if candidates else None
def fetch_assets(tag: str, repo: str) -> Any:
"""Asset names for one release, including authorized drafts."""
result = subprocess.run(
["gh", "release", "view", tag, "--repo", repo, "--json", "assets"],
check = False,
capture_output = True,
text = True,
)
if result.returncode != 0:
raise ValueError(f"could not inspect assets for {tag}: {result.stderr.strip()}")
return json.loads(result.stdout).get("assets")
def resolve_newest(releases: Any, asset_suffix: str, fetch) -> str | None:
"""Newest SemVer release carrying the asset, hydrating one release at a time.
Newest first and stop on the first match: hydrating every entry cost one
subprocess per release per matrix leg, and let a transient lookup on an
irrelevant historical release fail a leg whose answer was the newest one.
"""
if not isinstance(releases, list):
raise ValueError("release listing must be a JSON array")
candidates = [
(release["createdAt"], release["tagName"])
for release in releases
if isinstance(release, dict)
and isinstance(release.get("tagName"), str)
and SEMVER_TAG.fullmatch(release["tagName"])
and isinstance(release.get("createdAt"), str)
]
for created_at, tag in sorted(candidates, reverse = True):
if resolve([{"tagName": tag, "createdAt": created_at, "assets": fetch(tag)}], asset_suffix):
return tag
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--asset-suffix", required = True)
parser.add_argument("--repo", required = True)
args = parser.parse_args()
try:
tag = resolve_newest(
json.load(sys.stdin),
args.asset_suffix,
lambda release_tag: fetch_assets(release_tag, args.repo),
)
except (json.JSONDecodeError, ValueError) as error:
print(f"invalid GitHub release listing: {error}", file = sys.stderr)
return 2
if tag is None:
print(
f"no SemVer v... desktop release contains an asset ending in {args.asset_suffix}",
file = sys.stderr,
)
return 1
print(tag)
return 0
if __name__ == "__main__":
raise SystemExit(main())