mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-24 08:13:59 +00:00
2098 lines
100 KiB
YAML
2098 lines
100 KiB
YAML
name: Release Desktop App
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
# Both version inputs are free text in the dispatch UI, and studio_version
|
|
# alone decides the release tag, all nine asset filenames and the updater
|
|
# manifest version together. Near-miss tags are the failure mode worth
|
|
# guarding, because they look right at a glance:
|
|
#
|
|
# studio_version v0.1.52-beta correct
|
|
# v.0.1.52-beta rejected: stray dot after the leading v
|
|
# 0.1.52-beta rejected: missing leading v
|
|
# 2026.8.3 rejected: that is the PyPI version
|
|
#
|
|
# pypi_version 2026.8.3 correct, no leading v
|
|
#
|
|
# "Validate release versions" below rejects each of those by name before any
|
|
# binary is built. Keep the examples here, in the descriptions, and in that
|
|
# step's error messages on the same version so they cannot drift apart.
|
|
inputs:
|
|
studio_version:
|
|
description: 'Unsloth SemVer tag to release, leading v with no dot after it (for example, v0.1.52-beta)'
|
|
type: string
|
|
required: true
|
|
pypi_version:
|
|
description: 'Exact PyPI unsloth version just published/stamped, no leading v (for example, 2026.8.3); leave blank to use MIN_DESKTOP_BACKEND_VERSION'
|
|
type: string
|
|
required: false
|
|
draft:
|
|
description: 'Build and validate only; nothing is uploaded to the v... release and GitHub latest is left untouched'
|
|
type: boolean
|
|
default: true
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
env:
|
|
DESKTOP_RELEASE_NOTES: |
|
|
Desktop app for Unsloth.
|
|
|
|
**macOS**: Download the Apple Silicon `.dmg`.
|
|
**Windows**: Download the `-setup.exe` installer.
|
|
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
|
|
|
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
|
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
|
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
|
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
|
|
|
concurrency:
|
|
group: release-desktop-${{ github.repository }}
|
|
cancel-in-progress: false
|
|
# publish-desktop-updater.yml shares this group and already sets queue: max.
|
|
# Without it here, the default queue: single silently cancels a *pending*
|
|
# release the moment a second one is dispatched or a publish lands, which
|
|
# looks identical to a release that was never dispatched. A queued release
|
|
# holds no runner, so waiting is free.
|
|
queue: max
|
|
|
|
jobs:
|
|
# There is no capacity-sweeper dispatch here. It used to ask ci-capacity.yml to
|
|
# stand scheduled CI down while a release built, but that workflow is inert
|
|
# unless the repo variable CI_PREEMPT_ENABLED is the exact string 'true', so
|
|
# every release spent a runner on a job that resolved what it would have
|
|
# cancelled and then touched nothing. Queue clearing is done by hand now.
|
|
prepare-version:
|
|
name: Prepare release versions
|
|
runs-on: ubuntu-latest
|
|
# Version resolution only; observed 8-16s.
|
|
timeout-minutes: 15
|
|
outputs:
|
|
studio_version: ${{ steps.prepare.outputs.studio_version }}
|
|
app_version: ${{ steps.prepare.outputs.app_version }}
|
|
asset_version: ${{ steps.prepare.outputs.asset_version }}
|
|
desktop_release_tag: ${{ steps.prepare.outputs.desktop_release_tag }}
|
|
pypi_version: ${{ steps.backend.outputs.pypi_version }}
|
|
|
|
steps:
|
|
# Before the checkout: for a malformed studio_version, actions/checkout would
|
|
# fail on the ref with a generic error and none of the corrections below
|
|
# would ever be printed.
|
|
- name: Validate release versions
|
|
id: prepare
|
|
shell: bash
|
|
env:
|
|
INPUT_STUDIO_VERSION: ${{ inputs.studio_version }}
|
|
# Reading releases unauthenticated spends a quota shared by every runner.
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
python3 <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
|
|
RELEASES_API_URL = (
|
|
'https://api.github.com/repos/unslothai/unsloth/releases?per_page=30'
|
|
)
|
|
|
|
studio_version = os.environ['INPUT_STUDIO_VERSION'].strip()
|
|
if not studio_version:
|
|
sys.exit('studio_version is required, for example v0.1.52-beta')
|
|
if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
|
|
sys.exit(f'studio_version must be an Unsloth SemVer tag, not a date-style backend version: {studio_version}')
|
|
|
|
# Name the single wrong character for the near-miss shapes. The generic
|
|
# SemVer error below is easy to skim past when everything except one
|
|
# separator looks correct, so spell out the correction instead.
|
|
separator_after_v = re.fullmatch(r'v[._\-](\d[0-9A-Za-z.\-+]*)', studio_version)
|
|
if separator_after_v:
|
|
suggestion = f'v{separator_after_v.group(1)}'
|
|
sys.exit(
|
|
'studio_version must not have a separator after the leading v: '
|
|
f'{studio_version} (did you mean {suggestion}?)'
|
|
)
|
|
|
|
semver_tag = re.compile(
|
|
r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
|
r'(?:-[0-9A-Za-z.][0-9A-Za-z.-]*)?$'
|
|
)
|
|
if semver_tag.fullmatch(f'v{studio_version}'):
|
|
sys.exit(f'studio_version must start with v, for example v{studio_version}')
|
|
if not semver_tag.fullmatch(studio_version):
|
|
sys.exit(f'studio_version must be a SemVer tag with leading v, for example v0.1.52-beta: {studio_version}')
|
|
|
|
app_version = studio_version.removeprefix('v')
|
|
asset_version = re.sub(r'[^0-9A-Za-z]+', '_', app_version).strip('_')
|
|
desktop_release_tag = studio_version
|
|
|
|
# Both updater paths compare SemVer, so a tag whose numbers go backwards
|
|
# strands its own clients: 60 < 527, so v0.1.60-beta offered no one.
|
|
def semver_key(version):
|
|
# Full precedence, not just the triple: v0.1.528 and v0.1.528-rc
|
|
# both beat v0.1.528-beta, and the updater knows it.
|
|
version = version.strip().removeprefix('v')
|
|
match = semver_tag.fullmatch(f'v{version}')
|
|
if not match:
|
|
return None
|
|
release = tuple(int(part) for part in match.groups())
|
|
pre = version.partition('-')[2]
|
|
if not pre:
|
|
return release, (1,) # a release outranks its own prereleases
|
|
# SemVer: numeric identifiers sort below alphanumeric, as numbers.
|
|
return release, (0, *(
|
|
(0, int(part), '') if part.isdigit() else (1, 0, part)
|
|
for part in pre.split('.')
|
|
))
|
|
|
|
def fetch(url):
|
|
headers = {'Accept': 'application/vnd.github+json'}
|
|
token = os.environ.get('GH_TOKEN', '')
|
|
if token:
|
|
headers['Authorization'] = f'Bearer {token}'
|
|
with urllib.request.urlopen(
|
|
urllib.request.Request(url, headers = headers), timeout = 30
|
|
) as response:
|
|
return json.loads(response.read())
|
|
|
|
def published_desktop_version():
|
|
# Not /releases/latest: an llama.cpp prebuilt can hold that slot
|
|
# and ships no manifest, so the read would 404 and skip this check.
|
|
studio = [
|
|
release for release in fetch(RELEASES_API_URL)
|
|
if not release['draft'] and semver_tag.fullmatch(release['tag_name'])
|
|
]
|
|
studio.sort(key = lambda release: release['published_at'], reverse = True)
|
|
for release in studio:
|
|
for asset in release['assets']:
|
|
if asset['name'] == 'latest.json':
|
|
return fetch(asset['browser_download_url']).get('version', '')
|
|
return ''
|
|
|
|
try:
|
|
published = published_desktop_version()
|
|
except (OSError, ValueError, KeyError) as error:
|
|
# An unreachable manifest is not a bad tag, and the first has none.
|
|
print(f'::warning::could not read the published desktop version: {error}')
|
|
published = ''
|
|
|
|
published_key = semver_key(published)
|
|
if published_key and published_key >= semver_key(app_version):
|
|
sys.exit(
|
|
f'studio_version {studio_version} is not newer than the published '
|
|
f'v{published}, so clients on it would never be offered this release'
|
|
)
|
|
|
|
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
|
|
print(f'studio_version={studio_version}', file=output)
|
|
print(f'app_version={app_version}', file=output)
|
|
print(f'asset_version={asset_version}', file=output)
|
|
print(f'desktop_release_tag={desktop_release_tag}', file=output)
|
|
|
|
PY
|
|
|
|
# studio_version names an existing tag, so the backend pin and the stamp
|
|
# check have to come from that tag, not a dispatch ref that has moved on.
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ steps.prepare.outputs.studio_version }}
|
|
persist-credentials: false
|
|
|
|
- name: Resolve the backend pin from the release tag
|
|
id: backend
|
|
shell: bash
|
|
env:
|
|
INPUT_PYPI_VERSION: ${{ inputs.pypi_version }}
|
|
run: |
|
|
python3 <<'PY'
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
def parse_backend_version(version):
|
|
match = re.fullmatch(
|
|
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
|
r'(?:([a-zA-Z]|\.dev|dev|\.rc|rc|\.post|post)(\d*))?'
|
|
r'(?:[-+]([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?',
|
|
version,
|
|
)
|
|
if not match:
|
|
return None
|
|
major, minor, patch, suffix_name, suffix_number, suffix_text = match.groups()
|
|
if suffix_name:
|
|
normalized = suffix_name.lower().lstrip('.')
|
|
order = {'dev': 0, 'a': 1, 'b': 2, 'rc': 3, 'post': 5}.get(normalized)
|
|
if order is None:
|
|
return None
|
|
number = int(suffix_number or '0')
|
|
elif suffix_text:
|
|
order = 3 if version[version.find(suffix_text) - 1] == '-' else 4
|
|
number = 0
|
|
else:
|
|
order = 4
|
|
number = 0
|
|
return (int(major), int(minor), int(patch), order, number)
|
|
|
|
preflight = pathlib.Path('studio/src-tauri/src/preflight/version.rs').read_text()
|
|
match = re.search(r'MIN_DESKTOP_BACKEND_VERSION:\s*&str\s*=\s*"([^"]+)"', preflight)
|
|
if not match:
|
|
sys.exit('Could not read MIN_DESKTOP_BACKEND_VERSION')
|
|
min_backend_version = match.group(1)
|
|
|
|
input_pypi_version = os.environ.get('INPUT_PYPI_VERSION', '').strip()
|
|
parsed_min_backend = parse_backend_version(min_backend_version)
|
|
if parsed_min_backend is None:
|
|
sys.exit(f'MIN_DESKTOP_BACKEND_VERSION is not a supported backend package version: {min_backend_version}')
|
|
|
|
pypi_version = input_pypi_version or min_backend_version
|
|
parsed_pypi = parse_backend_version(pypi_version)
|
|
if parsed_pypi is None:
|
|
sys.exit(f'pypi_version is not a supported backend package version: {pypi_version}')
|
|
if parsed_pypi < parsed_min_backend:
|
|
sys.exit(
|
|
f'pypi_version {pypi_version} is lower than desktop minimum '
|
|
f'MIN_DESKTOP_BACKEND_VERSION {min_backend_version}'
|
|
)
|
|
|
|
if input_pypi_version:
|
|
print(
|
|
'Using exact PyPI unsloth version from pypi_version input: '
|
|
f'{pypi_version} (desktop minimum: {min_backend_version})'
|
|
)
|
|
else:
|
|
print(
|
|
'Using exact PyPI unsloth version from MIN_DESKTOP_BACKEND_VERSION: '
|
|
f'{pypi_version}'
|
|
)
|
|
|
|
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
|
|
print(f'pypi_version={pypi_version}', file=output)
|
|
|
|
PY
|
|
|
|
# Fail before building and notarizing a version publish-release will reject.
|
|
- name: Guard against republishing an existing version
|
|
shell: bash
|
|
env:
|
|
GH_REPO: ${{ github.repository }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
DESKTOP_RELEASE_TAG: ${{ steps.prepare.outputs.desktop_release_tag }}
|
|
ASSET_VERSION: ${{ steps.prepare.outputs.asset_version }}
|
|
run: |
|
|
set -euo pipefail
|
|
# v{version} is tagged and published when main is tagged, before this runs,
|
|
# so the tag existing is normal. What must be absent is the desktop asset set.
|
|
if ! response="$(gh api --include "repos/${GH_REPO}/releases/tags/${DESKTOP_RELEASE_TAG}" 2>&1)"; then
|
|
if [[ "$response" =~ ^HTTP/[0-9.]+\ 404\ ]]; then
|
|
echo "Release ${DESKTOP_RELEASE_TAG} does not exist." >&2
|
|
echo " Tag main and publish it first, then re-dispatch this workflow." >&2
|
|
exit 1
|
|
fi
|
|
echo "Could not read release ${DESKTOP_RELEASE_TAG}; refusing to publish." >&2
|
|
printf '%s\n' "$response" >&2
|
|
exit 1
|
|
fi
|
|
gh api "repos/${GH_REPO}/releases/tags/${DESKTOP_RELEASE_TAG}" > "$RUNNER_TEMP/target-release.json"
|
|
# A carried-forward manifest for an older version is expected here (see
|
|
# publish-desktop-updater.yml), so read it rather than judge by name alone.
|
|
mkdir -p "$RUNNER_TEMP/target-manifest"
|
|
gh release download "$DESKTOP_RELEASE_TAG" --pattern latest.json \
|
|
--dir "$RUNNER_TEMP/target-manifest" --clobber 2>/dev/null || true
|
|
python3 <<'PY'
|
|
import json, os, pathlib, sys
|
|
|
|
runner = pathlib.Path(os.environ['RUNNER_TEMP'])
|
|
release = json.loads((runner / 'target-release.json').read_text())
|
|
tag = os.environ['DESKTOP_RELEASE_TAG']
|
|
# Checked here, not at promotion: GitHub refuses to mark a prerelease
|
|
# latest, and by then the bundles are already public.
|
|
if release.get('prerelease'):
|
|
sys.exit(f'Release {tag} is a prerelease; GitHub cannot mark it latest.')
|
|
|
|
prefix = f'Unsloth-Desktop-{os.environ["ASSET_VERSION"]}'
|
|
clashing = sorted(
|
|
asset['name']
|
|
for asset in release.get('assets', [])
|
|
if str(asset.get('name', '')).startswith(prefix)
|
|
)
|
|
manifest_path = runner / 'target-manifest' / 'latest.json'
|
|
if manifest_path.is_file():
|
|
try:
|
|
version = json.loads(manifest_path.read_text()).get('version')
|
|
except json.JSONDecodeError:
|
|
version = None
|
|
# Only this version's own manifest is a clash; anything else is the
|
|
# pointer being carried forward and is overwritten on upload.
|
|
if not isinstance(version, str) or version.removeprefix('v') == tag.removeprefix('v'):
|
|
clashing.append('latest.json')
|
|
|
|
if clashing:
|
|
print(f'Refusing to republish {tag}: it already carries desktop assets.', file=sys.stderr)
|
|
for name in clashing:
|
|
print(f' gh release delete-asset {tag} {name} --yes', file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
|
|
- name: Verify PyPI package and Unsloth stamp
|
|
shell: bash
|
|
env:
|
|
STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
|
|
PYPI_VERSION: ${{ steps.prepare.outputs.pypi_version }}
|
|
run: |
|
|
set -euo pipefail
|
|
python3 <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
pypi_version = os.environ['PYPI_VERSION']
|
|
dist_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'pypi-unsloth-dist')
|
|
dist_dir.mkdir(parents=True, exist_ok=True)
|
|
metadata_url = f'https://pypi.org/pypi/unsloth/{pypi_version}/json'
|
|
|
|
last_error = None
|
|
for attempt in range(1, 6):
|
|
try:
|
|
with urllib.request.urlopen(metadata_url, timeout=30) as response:
|
|
metadata = json.load(response)
|
|
break
|
|
except Exception as exc:
|
|
last_error = exc
|
|
if attempt < 5:
|
|
time.sleep(10 * attempt)
|
|
else:
|
|
sys.exit(f'Publish unsloth=={pypi_version} to PyPI before the desktop release ({last_error})')
|
|
|
|
files = metadata.get('urls') or []
|
|
if not files:
|
|
sys.exit(f'PyPI returned no distribution files for unsloth=={pypi_version}')
|
|
|
|
for file_info in files:
|
|
filename = file_info.get('filename')
|
|
url = file_info.get('url')
|
|
if not filename or '/' in filename or not url:
|
|
sys.exit(f'Unexpected PyPI file entry for unsloth=={pypi_version}: {file_info!r}')
|
|
target = dist_dir / filename
|
|
for attempt in range(1, 4):
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=60) as response:
|
|
target.write_bytes(response.read())
|
|
break
|
|
except Exception as exc:
|
|
last_error = exc
|
|
if attempt < 3:
|
|
time.sleep(5 * attempt)
|
|
else:
|
|
sys.exit(f'Could not download {filename} from PyPI ({last_error})')
|
|
PY
|
|
|
|
if [ "$GITHUB_REPOSITORY" = "wasimysaid/unsloth" ]; then
|
|
echo "Fork-only updater A/B build: skipping the upstream PyPI-to-Studio release stamp check."
|
|
elif [ -f scripts/stamp_studio_release.py ]; then
|
|
mapfile -t dists < <(find "$RUNNER_TEMP/pypi-unsloth-dist" -type f \( -name '*.whl' -o -name '*.tar.gz' \) | sort)
|
|
if [ "${#dists[@]}" -eq 0 ]; then
|
|
echo "No PyPI wheel/sdist artifacts downloaded for unsloth==$PYPI_VERSION" >&2
|
|
exit 1
|
|
fi
|
|
python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
|
|
else
|
|
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Unsloth stamp." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Guard GitHub latest release version
|
|
if: ${{ !inputs.draft }}
|
|
shell: bash
|
|
env:
|
|
GH_REPO: ${{ github.repository }}
|
|
GH_TOKEN: ${{ github.token }}
|
|
APP_VERSION: ${{ steps.prepare.outputs.app_version }}
|
|
run: |
|
|
set -euo pipefail
|
|
current_file="$RUNNER_TEMP/current-latest-release.json"
|
|
error_file="$RUNNER_TEMP/current-latest-release.err"
|
|
if ! gh api "repos/${GH_REPO}/releases/latest" > "$current_file" 2> "$error_file"; then
|
|
if grep -Fq '(HTTP 404)' "$error_file"; then
|
|
echo "No existing normal GitHub release found; allowing first latest release."
|
|
exit 0
|
|
fi
|
|
cat "$error_file" >&2
|
|
exit 1
|
|
fi
|
|
python3 - "$current_file" <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
def parse(value: str):
|
|
value = value.removeprefix('v')
|
|
match = re.fullmatch(
|
|
r'(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
|
|
r'(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'
|
|
r'(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
|
|
value,
|
|
)
|
|
if not match:
|
|
sys.exit(f'GitHub latest release is not a desktop SemVer tag: {value}')
|
|
major, minor, patch, prerelease = match.groups()
|
|
return (int(major), int(minor), int(patch), prerelease)
|
|
|
|
def compare_identifier(left: str, right: str) -> int:
|
|
if left.isdigit() and right.isdigit():
|
|
return (int(left) > int(right)) - (int(left) < int(right))
|
|
if left.isdigit():
|
|
return -1
|
|
if right.isdigit():
|
|
return 1
|
|
left_tail = re.fullmatch(r'([A-Za-z-]+)(\d+)', left)
|
|
right_tail = re.fullmatch(r'([A-Za-z-]+)(\d+)', right)
|
|
if left_tail and right_tail and left_tail.group(1).lower() == right_tail.group(1).lower():
|
|
return (int(left_tail.group(2)) > int(right_tail.group(2))) - (
|
|
int(left_tail.group(2)) < int(right_tail.group(2))
|
|
)
|
|
return (left > right) - (left < right)
|
|
|
|
def compare(left: str, right: str) -> int:
|
|
left_major, left_minor, left_patch, left_pre = parse(left)
|
|
right_major, right_minor, right_patch, right_pre = parse(right)
|
|
left_core = (left_major, left_minor, left_patch)
|
|
right_core = (right_major, right_minor, right_patch)
|
|
if left_core != right_core:
|
|
return (left_core > right_core) - (left_core < right_core)
|
|
if left_pre == right_pre:
|
|
return 0
|
|
if left_pre is None:
|
|
return 1
|
|
if right_pre is None:
|
|
return -1
|
|
for left_part, right_part in zip(left_pre.split('.'), right_pre.split('.')):
|
|
order = compare_identifier(left_part, right_part)
|
|
if order:
|
|
return order
|
|
return (len(left_pre.split('.')) > len(right_pre.split('.'))) - (
|
|
len(left_pre.split('.')) < len(right_pre.split('.'))
|
|
)
|
|
|
|
current = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
current_tag = current.get('tag_name')
|
|
if not isinstance(current_tag, str):
|
|
sys.exit('GitHub latest release is missing tag_name')
|
|
next_version = os.environ['APP_VERSION']
|
|
if compare(next_version, current_tag) < 0:
|
|
sys.exit(f'Refusing to publish {next_version}; GitHub latest is newer: {current_tag}.')
|
|
PY
|
|
|
|
build:
|
|
strategy:
|
|
fail-fast: false
|
|
# Legs run in parallel: they share no state, only upload per-platform
|
|
# artifacts, and publish-release does every release mutation once.
|
|
matrix:
|
|
include:
|
|
- platform: macos-latest
|
|
args: '--target aarch64-apple-darwin'
|
|
label: macOS (Apple Silicon)
|
|
artifact: macos-aarch64
|
|
# - platform: macos-latest
|
|
# args: '--target x86_64-apple-darwin'
|
|
# label: macOS (Intel)
|
|
- platform: ubuntu-22.04
|
|
args: ''
|
|
label: Linux (x64)
|
|
artifact: linux-x64
|
|
- platform: windows-latest
|
|
args: ''
|
|
label: Windows (x64)
|
|
artifact: windows-x64
|
|
|
|
name: Build ${{ matrix.label }}
|
|
needs: prepare-version
|
|
runs-on: ${{ matrix.platform }}
|
|
# 6.9-20.9 min observed across platforms and cache states.
|
|
timeout-minutes: 60
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
|
ASSET_VERSION: ${{ needs.prepare-version.outputs.asset_version }}
|
|
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
|
|
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
|
# Compiled in by preflight/version.rs. Without it the app only knows the
|
|
# floor, so a shared venv left by the CLI installer is never upgraded again.
|
|
UNSLOTH_DESKTOP_BACKEND_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
|
|
|
|
steps:
|
|
# harden-runner in audit mode: surfaces every egress destination in
|
|
# the runner log so the allowlist for a future `egress-policy: block`
|
|
# promotion can be derived from observed traffic. Blocking mode is
|
|
# supported on GitHub-hosted Linux / macOS / Windows runners as of
|
|
# harden-runner v2.20.0; stay in audit until the macOS + Windows
|
|
# codesign paths have been observed.
|
|
- name: Harden runner (audit)
|
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
|
with:
|
|
egress-policy: audit
|
|
|
|
# The tag, not the dispatch ref: the release is published before this runs and
|
|
# main keeps moving, so building the dispatch ref would attach unrelated source
|
|
# to the release and record a SHA that is not the tag's.
|
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
with:
|
|
ref: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
|
persist-credentials: false
|
|
|
|
# ── Linux dependencies ──
|
|
- name: Install Linux dependencies
|
|
if: matrix.platform == 'ubuntu-22.04'
|
|
# Bounded and retried through the shared helper: an unbounded apt step does
|
|
# not fail, it spends the job's whole budget and is reported as "cancelled"
|
|
# with no reason and every later step skipped. update and install go as one
|
|
# unit, since retrying the install after a stalled update re-reads the same
|
|
# broken package list.
|
|
# Two long attempts, not three short ones. 150s killed apt mid-`update`
|
|
# against a mirror that was degraded rather than dead, and every attempt
|
|
# then hit the same wall -- three kills and no result. The bound exists to
|
|
# stop an infinite hang, not to race a slow mirror.
|
|
timeout-minutes: 15
|
|
env:
|
|
RETRY_ATTEMPTS: '2'
|
|
RETRY_ATTEMPT_TIMEOUT: '360'
|
|
run: |
|
|
# Install the GStreamer plugins needed for H.264 playback and capture.
|
|
bash .github/scripts/retry-with-apt-lock.sh sudo sh -c \
|
|
'apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf xdg-utils gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav || { apt-get update && apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf xdg-utils gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav; }'
|
|
|
|
# ── Node.js ──
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
with:
|
|
node-version: 24
|
|
|
|
- name: Install pinned Tauri CLI
|
|
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
|
|
# required for `vite build`. The pre-install lockfile structural
|
|
# audit (lockfile_supply_chain_audit.py) is the practical defence
|
|
# against the npm postinstall-dropper class -- it fires BEFORE any
|
|
# tarball runs, on the injection pattern itself rather than an
|
|
# advisory-DB lookup.
|
|
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1 --no-fund --no-audit
|
|
|
|
- name: Verify pinned Tauri CLI
|
|
shell: bash
|
|
run: |
|
|
out="$(npx --prefix studio tauri --version)"
|
|
echo "$out"
|
|
if [ "$out" != "tauri-cli 2.10.1" ]; then
|
|
echo "Expected tauri-cli 2.10.1, got $out" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Verify desktop updater and Linux package config
|
|
shell: bash
|
|
run: |
|
|
node <<'JS'
|
|
const { readFileSync } = require('node:fs');
|
|
|
|
const expected = 'https://github.com/unslothai/unsloth/releases/latest/download/latest.json';
|
|
const config = JSON.parse(readFileSync('studio/src-tauri/tauri.conf.json', 'utf8'));
|
|
const endpoints = config.plugins?.updater?.endpoints;
|
|
if (!Array.isArray(endpoints) || endpoints.length !== 1) {
|
|
throw new Error('Expected exactly one desktop updater endpoint');
|
|
}
|
|
if (endpoints[0] !== expected) {
|
|
throw new Error('Desktop updater endpoint must be ' + expected + ', got ' + endpoints[0]);
|
|
}
|
|
if (!endpoints[0].includes('/releases/latest/download/latest.json')) {
|
|
throw new Error('Desktop updater endpoint must use repo-wide /releases/latest/ discovery');
|
|
}
|
|
|
|
const targets = config.bundle?.targets;
|
|
if (Array.isArray(targets) && targets.some((target) => String(target).toLowerCase() === 'rpm')) {
|
|
throw new Error('Desktop release must not target RPM packages');
|
|
}
|
|
if (!Array.isArray(targets) || !targets.some((target) => String(target).toLowerCase() === 'appimage')) {
|
|
throw new Error('Desktop release must build the complete Tauri AppImage target');
|
|
}
|
|
if (config.bundle?.linux?.rpm) {
|
|
throw new Error('bundle.linux.rpm must not be configured');
|
|
}
|
|
// The media plugins must match the bundled GStreamer core.
|
|
if (config.bundle?.linux?.appimage?.bundleMediaFramework !== true) {
|
|
throw new Error('Linux AppImage bundleMediaFramework must stay true');
|
|
}
|
|
|
|
const workflow = readFileSync('.github/workflows/release-desktop.yml', 'utf8');
|
|
const lines = workflow.split(/\r?\n/);
|
|
const linuxInstallLines = lines.filter((line) => line.includes('sudo apt-get install'));
|
|
const ayatanaPackage = ['libayatana', 'appindicator3-dev'].join('-');
|
|
if (linuxInstallLines.some((line) => line.includes(ayatanaPackage))) {
|
|
throw new Error('Desktop Linux release must not install the Ayatana appindicator dev package');
|
|
}
|
|
if (!linuxInstallLines.some((line) => line.includes('libappindicator3-dev'))) {
|
|
throw new Error('Desktop Linux release must install libappindicator3-dev');
|
|
}
|
|
const requiredFetchedTools = [
|
|
'linuxdeploy-x86_64.AppImage',
|
|
'linuxdeploy-plugin-gtk.sh',
|
|
'linuxdeploy-plugin-gstreamer.sh',
|
|
'linuxdeploy-plugin-appimage.AppImage',
|
|
];
|
|
const toolScript = readFileSync(
|
|
'studio/src-tauri/linux/prepare-complete-appimage-tools.sh',
|
|
'utf8',
|
|
);
|
|
const localAppRunInstall =
|
|
'install -m 755 "$script_dir/appimage-apprun.sh" "$tools_dir/AppRun-x86_64"';
|
|
if (!toolScript.includes(localAppRunInstall)) {
|
|
throw new Error('Desktop Linux release must install the repository AppRun');
|
|
}
|
|
const fetchCalls = toolScript
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => /^fetch "\$[A-Z_]+_URL" "\$[A-Z_]+_SHA256" \S+$/.test(line));
|
|
const fetchedTools = fetchCalls.map((line) => line.split(/\s+/).at(-1));
|
|
if (fetchCalls.length !== requiredFetchedTools.length) {
|
|
throw new Error(
|
|
`Expected ${requiredFetchedTools.length} digest-verified fetch calls, got ${fetchCalls.length}`,
|
|
);
|
|
}
|
|
for (const tool of requiredFetchedTools) {
|
|
if (!fetchedTools.includes(tool)) {
|
|
throw new Error(`Desktop Linux release must digest-verify its fetch of ${tool}`);
|
|
}
|
|
}
|
|
const releaseBody = process.env.DESKTOP_RELEASE_NOTES;
|
|
if (!releaseBody) throw new Error('DESKTOP_RELEASE_NOTES must not be empty');
|
|
if (/\brpm\b|\.rpm/i.test(releaseBody)) {
|
|
throw new Error('Desktop release body must not advertise RPM packages');
|
|
}
|
|
if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) {
|
|
throw new Error('Desktop release body must not advertise AppImage as universal');
|
|
}
|
|
if (!/AppImage.*experimental/i.test(releaseBody)) {
|
|
throw new Error('Desktop release body must mark AppImage as experimental');
|
|
}
|
|
JS
|
|
|
|
- name: Install frontend dependencies
|
|
working-directory: studio/frontend
|
|
# Lifecycle scripts (esbuild native-binary postinstall, etc.) are
|
|
# required for `vite build`. The pre-install lockfile structural
|
|
# audit (lockfile_supply_chain_audit.py) is the practical defence
|
|
# against the npm postinstall-dropper class -- it fires BEFORE any
|
|
# tarball runs, on the injection pattern itself rather than an
|
|
# advisory-DB lookup.
|
|
run: npm install --no-fund --no-audit
|
|
|
|
# ── Rust ──
|
|
- name: Install Rust stable
|
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27
|
|
with:
|
|
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
|
|
|
- name: Patch desktop app version
|
|
shell: bash
|
|
working-directory: studio/src-tauri
|
|
run: |
|
|
set -euo pipefail
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
PYTHON=python3
|
|
else
|
|
PYTHON=python
|
|
fi
|
|
"$PYTHON" <<'PY'
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
app_version = os.environ['APP_VERSION']
|
|
if not app_version:
|
|
sys.exit('APP_VERSION is required')
|
|
|
|
cargo_toml = pathlib.Path('Cargo.toml')
|
|
lines = cargo_toml.read_text().splitlines(keepends=True)
|
|
in_package = False
|
|
patched = False
|
|
for index, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if stripped == '[package]':
|
|
in_package = True
|
|
continue
|
|
if stripped.startswith('[') and stripped.endswith(']'):
|
|
in_package = False
|
|
if in_package and re.fullmatch(r'version\s*=\s*"[^"]+"\s*', stripped):
|
|
lines[index] = f'version = "{app_version}"\n'
|
|
patched = True
|
|
break
|
|
if not patched:
|
|
sys.exit('Could not patch [package] version in Cargo.toml')
|
|
cargo_toml.write_text(''.join(lines))
|
|
|
|
cargo_lock = pathlib.Path('Cargo.lock')
|
|
lock_text = cargo_lock.read_text()
|
|
lock_text, count = re.subn(
|
|
r'(?m)(^\[\[package\]\]\nname = "unsloth-studio"\nversion = ")[^"]+(")',
|
|
lambda match: f'{match.group(1)}{app_version}{match.group(2)}',
|
|
lock_text,
|
|
)
|
|
if count != 1:
|
|
sys.exit(f'Could not patch unsloth-studio version in Cargo.lock (matches={count})')
|
|
cargo_lock.write_text(lock_text)
|
|
PY
|
|
|
|
cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cargo-metadata.json"
|
|
"$PYTHON" <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
app_version = os.environ['APP_VERSION']
|
|
metadata = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'cargo-metadata.json').read_text())
|
|
versions = [package['version'] for package in metadata.get('packages', []) if package.get('name') == 'unsloth-studio']
|
|
if versions != [app_version]:
|
|
sys.exit(f'cargo metadata unsloth-studio version mismatch: expected {app_version}, got {versions}')
|
|
PY
|
|
|
|
git diff -- Cargo.toml Cargo.lock
|
|
|
|
- name: Rust cache
|
|
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
|
with:
|
|
workspaces: 'studio/src-tauri -> target'
|
|
|
|
# Windows release builds can spend many minutes uploading the target cache
|
|
# after signed artifacts are ready, which blocks the publisher for no release benefit.
|
|
save-if: ${{ matrix.platform != 'windows-latest' }}
|
|
|
|
# ── macOS: fail before the build if notarization cannot run ──
|
|
- name: Check Apple notarization credentials
|
|
if: matrix.platform == 'macos-latest'
|
|
shell: bash
|
|
env:
|
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
run: |
|
|
set -euo pipefail
|
|
# A missing secret arrives as an empty string, so `set -u` never sees
|
|
# it, and tauri-action just skips notarization instead of failing.
|
|
# Check before the release build so a rotated secret costs seconds
|
|
# rather than a full compile.
|
|
for required in APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do
|
|
if [ -z "${!required:-}" ]; then
|
|
echo "Missing $required; refusing to publish an unnotarized disk image." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# ── macOS: import signing certificate ──
|
|
- name: Import Apple certificate
|
|
if: matrix.platform == 'macos-latest'
|
|
env:
|
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
|
run: |
|
|
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
|
|
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
|
security default-keychain -s build.keychain
|
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
|
security set-keychain-settings -t 3600 -u build.keychain
|
|
security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
|
security find-identity -v -p codesigning build.keychain
|
|
rm -f certificate.p12
|
|
|
|
# ── Windows: install Azure Trusted Signing CLI ──
|
|
# This binary runs in the steps holding the Azure Trusted Signing and
|
|
# Tauri signing secrets, so where it comes from is a signing-key question,
|
|
# not a build-speed one. Caching the `cargo install` build (241s and 248s
|
|
# on the last two runs) made the cache the source of truth: a hit skipped
|
|
# the install, and the restored executable went on PATH gated only by a
|
|
# `--version` probe, which any binary can print.
|
|
#
|
|
# Upstream publishes the same tool prebuilt, so pin it instead. The URL is
|
|
# reproducibility, the SHA-256 is the integrity gate: a release asset can
|
|
# be replaced after upload, and a replaced one now fails the release
|
|
# rather than signing with an unknown binary. Same shape as the AppImage
|
|
# toolchain below, and faster than the cache it replaces. Bump the URL and
|
|
# the digest together when moving off 0.10.0.
|
|
- name: Install trusted-signing-cli
|
|
if: matrix.platform == 'windows-latest'
|
|
# Explicit, like every other Windows step here. The retry parameters
|
|
# below are PowerShell 6+ only, so under 5.1 the download fails with "A
|
|
# parameter cannot be found that matches parameter name
|
|
# 'MaximumRetryCount'". Naming the shell keeps that deliberate.
|
|
shell: pwsh
|
|
env:
|
|
TRUSTED_SIGNING_CLI_URL: "https://github.com/Levminer/artifact-signing-cli/releases/download/0.10.0/trusted-signing-cli.exe"
|
|
TRUSTED_SIGNING_CLI_SHA256: "8c9d750ca582891a7433925dcf302d5d3761fbed757b46c4bf5b417562dccb0e"
|
|
run: |
|
|
$ErrorActionPreference = "Stop"
|
|
$dir = Join-Path $env:RUNNER_TEMP "trusted-signing-cli"
|
|
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
$dest = Join-Path $dir "trusted-signing-cli.exe"
|
|
|
|
Invoke-WebRequest -Uri $env:TRUSTED_SIGNING_CLI_URL -OutFile $dest -MaximumRetryCount 3 -RetryIntervalSec 5
|
|
|
|
$actual = (Get-FileHash -Path $dest -Algorithm SHA256).Hash.ToLower()
|
|
$expected = $env:TRUSTED_SIGNING_CLI_SHA256.ToLower()
|
|
if ($actual -ne $expected) {
|
|
# Delete it first: a later step resolving this name off PATH must
|
|
# not find the rejected download still sitting there.
|
|
Remove-Item -Force $dest
|
|
Write-Output "::error::trusted-signing-cli digest mismatch, refusing to sign with an unverified binary. Expected $expected, got $actual. Re-pin TRUSTED_SIGNING_CLI_SHA256 deliberately if upstream republished the asset."
|
|
exit 1
|
|
}
|
|
Write-Output "verified trusted-signing-cli sha256=$actual"
|
|
|
|
# $GITHUB_PATH prepends, which is what this needs: swatinem/rust-cache
|
|
# restores ~/.cargo/bin, already on PATH, and a stale entry there can
|
|
# carry its own unverified copy of this binary. The verified one has
|
|
# to win the name lookup.
|
|
$dir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
|
|
|
# ── Windows: verify signing CLI is accessible ──
|
|
# Fails closed. This used to print a message and exit 0 on both branches,
|
|
# so a missing or broken binary reported success and the failure only
|
|
# surfaced later, inside the Tauri bundling step, as a signing error with
|
|
# no obvious cause.
|
|
- name: Verify trusted-signing-cli
|
|
if: matrix.platform == 'windows-latest'
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = "Stop"
|
|
Write-Output "PATH: $env:PATH"
|
|
|
|
$cli = Get-Command trusted-signing-cli -ErrorAction SilentlyContinue
|
|
if (-not $cli) {
|
|
Write-Output "::error::trusted-signing-cli is not on PATH. Check the 'Install trusted-signing-cli' step above."
|
|
exit 1
|
|
}
|
|
Write-Output "resolved: $($cli.Source)"
|
|
|
|
# The signing script invokes this by bare name, so whatever PATH
|
|
# resolves is what signs. Only the digest-verified copy installed
|
|
# above may do that; anything else on PATH under the same name (a
|
|
# leftover in ~/.cargo/bin, say) is unverified by definition.
|
|
$verified = [IO.Path]::GetFullPath((Join-Path (Join-Path $env:RUNNER_TEMP "trusted-signing-cli") "trusted-signing-cli.exe"))
|
|
if ([IO.Path]::GetFullPath($cli.Source) -ne $verified) {
|
|
Write-Output "::error::trusted-signing-cli resolved to $($cli.Source), not the verified $verified. Refusing to sign with a binary that was never digest checked."
|
|
exit 1
|
|
}
|
|
|
|
# Two different failure modes, and neither one covers the other:
|
|
# * the binary cannot be started at all (a truncated download gives
|
|
# "Exec format error"). That raises a terminating error under
|
|
# $ErrorActionPreference = "Stop", so it has to be caught or the
|
|
# step dies here, before the message below.
|
|
# * the binary starts and exits non-zero. That does NOT raise, so
|
|
# $LASTEXITCODE has to be read explicitly.
|
|
try {
|
|
& $cli.Source --version
|
|
}
|
|
catch {
|
|
# The PowerShell error spans several lines (message, offending line,
|
|
# caret). An annotation is truncated at the first newline, so flatten
|
|
# it, and put the recovery ahead of the detail so it survives even if
|
|
# the annotation is clipped for length.
|
|
$detail = ($_.Exception.Message -replace '\s+', ' ').Trim()
|
|
Write-Output "::error::trusted-signing-cli could not be started. Re-run to fetch it again; if it persists the pinned asset is bad. Underlying error: $detail"
|
|
exit 1
|
|
}
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Output "::error::trusted-signing-cli is on PATH but exited $LASTEXITCODE."
|
|
exit 1
|
|
}
|
|
|
|
# ── Linux: preseed every executable/script consumed by Tauri's AppImage bundler ──
|
|
- name: Pin complete AppImage toolchain
|
|
if: matrix.platform == 'ubuntu-22.04'
|
|
shell: bash
|
|
run: |
|
|
studio/src-tauri/linux/prepare-complete-appimage-tools.sh \
|
|
"$RUNNER_TEMP/tauri-tools-cache/tauri"
|
|
|
|
# ── Linux: build and sign the native package and complete AppImage together ──
|
|
- name: Build Linux bundles
|
|
id: build_linux
|
|
if: matrix.platform == 'ubuntu-22.04'
|
|
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
|
env:
|
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
|
|
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
|
|
with:
|
|
projectPath: studio
|
|
tauriScript: npx --prefix . tauri
|
|
args: -v --bundles deb,appimage
|
|
|
|
- name: Verify complete Linux AppImage
|
|
if: matrix.platform == 'ubuntu-22.04'
|
|
shell: bash
|
|
env:
|
|
ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths }}
|
|
run: |
|
|
set -euo pipefail
|
|
appimage="$(python3 - <<'PY'
|
|
import json, os, pathlib, sys
|
|
matches = [pathlib.Path(p) for p in json.loads(os.environ['ARTIFACT_PATHS']) if p.endswith('.AppImage')]
|
|
if len(matches) != 1 or not matches[0].is_file():
|
|
sys.exit(f'Expected exactly one AppImage artifact, got {matches}')
|
|
print(matches[0])
|
|
PY
|
|
)"
|
|
studio/src-tauri/linux/verify-complete-appimage.sh "$appimage"
|
|
|
|
# ── macOS: build + sign + notarize ──
|
|
- name: Build macOS app
|
|
id: build_macos
|
|
if: matrix.platform == 'macos-latest'
|
|
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
|
env:
|
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
# The signing key is not password protected, and no secret of this name
|
|
# exists, so this expression has always resolved to the empty string.
|
|
# Stated literally rather than left pointing at a secret that is not
|
|
# there: the value is unchanged, but it no longer reads as configuration
|
|
# someone forgot to set. Empty is not the same as unset here -- tauri
|
|
# prompts for a password when the variable is absent, which would hang
|
|
# the job, so the line stays.
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
|
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
with:
|
|
projectPath: studio
|
|
tauriScript: npx --prefix . tauri
|
|
args: -v ${{ matrix.args }}
|
|
|
|
# ── macOS: notarize + staple the disk image itself ──
|
|
# tauri-bundler notarizes and staples the .app, but only codesigns the
|
|
# .dmg it wraps around it (tauri-apps/tauri#7533). The disk image users
|
|
# actually download therefore ships signed but without a notarization
|
|
# ticket, so Gatekeeper has to phone home to clear it and blocks it
|
|
# outright offline. Close that gap here and fail the release if any part
|
|
# of it does not hold.
|
|
- name: Notarize final macOS disk image
|
|
if: matrix.platform == 'macos-latest'
|
|
shell: bash
|
|
# `--timeout` below caps the polling, but not the upload that precedes
|
|
# it. Without a step-level backstop a stuck Notary service would burn
|
|
# the whole job budget, so cap it tighter than the job here.
|
|
timeout-minutes: 45
|
|
env:
|
|
ARTIFACT_PATHS: ${{ steps.build_macos.outputs.artifactPaths }}
|
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
run: |
|
|
set -euo pipefail
|
|
dmg_path="$(
|
|
python3 <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
raw_paths = os.environ.get('ARTIFACT_PATHS', '')
|
|
try:
|
|
artifact_paths = json.loads(raw_paths)
|
|
except json.JSONDecodeError as error:
|
|
sys.exit(f'Invalid tauri-action artifactPaths output: {error}')
|
|
if not isinstance(artifact_paths, list):
|
|
sys.exit('tauri-action artifactPaths must be a JSON list')
|
|
matches = [
|
|
path
|
|
for path in artifact_paths
|
|
if isinstance(path, str) and path.lower().endswith('.dmg')
|
|
]
|
|
if len(matches) != 1:
|
|
sys.exit(f'Expected exactly one final DMG from tauri-action, found {len(matches)}')
|
|
print(matches[0])
|
|
PY
|
|
)"
|
|
|
|
# First gate, before the multi-minute upload: the bundler signs the
|
|
# DMG whenever a signing identity is configured, so an unsigned image
|
|
# here means the signing step silently did nothing.
|
|
codesign --verify --verbose=2 "$dmg_path"
|
|
|
|
# notarytool has no environment or stdin form for --password, so the
|
|
# credentials are visible in argv and in the process table. That is
|
|
# the accepted tradeoff on a single-tenant ephemeral runner;
|
|
# --keychain-profile only moves the same arguments one command
|
|
# earlier, and the one form without argv secrets is App Store Connect
|
|
# API key auth, which needs a different secret type.
|
|
submit_status=0
|
|
submission="$(
|
|
xcrun notarytool submit "$dmg_path" \
|
|
--apple-id "$APPLE_ID" \
|
|
--password "$APPLE_PASSWORD" \
|
|
--team-id "$APPLE_TEAM_ID" \
|
|
--output-format json \
|
|
--timeout 30m \
|
|
--wait
|
|
)" || submit_status=$?
|
|
printf '%s\n' "$submission"
|
|
submission_id="$(
|
|
printf '%s' "$submission" \
|
|
| python3 -c 'import json, sys; print(json.load(sys.stdin).get("id", ""))' \
|
|
2>/dev/null
|
|
)" || submission_id=""
|
|
submission_result="$(
|
|
printf '%s' "$submission" \
|
|
| python3 -c 'import json, sys; print(json.load(sys.stdin).get("status", ""))' \
|
|
2>/dev/null
|
|
)" || submission_result=""
|
|
if [ "$submit_status" -ne 0 ] || [ "$submission_result" != "Accepted" ]; then
|
|
# The service verdict is part of the JSON contract, independently
|
|
# of the command's exit code. Fetch the log whenever the response
|
|
# identifies a failed submission.
|
|
if [ -n "$submission_id" ]; then
|
|
xcrun notarytool log "$submission_id" \
|
|
--apple-id "$APPLE_ID" \
|
|
--password "$APPLE_PASSWORD" \
|
|
--team-id "$APPLE_TEAM_ID" >&2 || true
|
|
fi
|
|
if [ "$submit_status" -ne 0 ]; then
|
|
exit "$submit_status"
|
|
fi
|
|
echo "Notarization did not return Accepted (status=${submission_result:-missing})." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Apple's ticket lookup can lag behind an Accepted verdict or time
|
|
# out against CloudKit. Retry it without weakening final validation.
|
|
for attempt in 1 2 3; do
|
|
staple_status=0
|
|
xcrun stapler staple "$dmg_path" || staple_status=$?
|
|
if [ "$staple_status" -eq 0 ]; then
|
|
break
|
|
fi
|
|
if [ "$attempt" -eq 3 ]; then
|
|
exit "$staple_status"
|
|
fi
|
|
delay=$((15 * attempt))
|
|
echo "::warning::Stapling failed (attempt $attempt/3); retrying in ${delay}s."
|
|
sleep "$delay"
|
|
done
|
|
# Stapling rewrites the image. `stapler validate` is what proves the
|
|
# offline case, since spctl can clear an unstapled image from an
|
|
# online CDN check; spctl then gives the verdict a user's machine
|
|
# would reach.
|
|
xcrun stapler validate "$dmg_path"
|
|
spctl --assess \
|
|
--type open \
|
|
--context context:primary-signature \
|
|
--verbose=4 \
|
|
"$dmg_path"
|
|
|
|
# ── Windows: build + sign ──
|
|
# install.ps1 is a bundle resource the bundler never signs, yet the app
|
|
# runs it first. Sign before the build packs it in; the signature is an
|
|
# appended comment block, so the repo copy for `irm ... | iex` is unaffected.
|
|
- name: Sign the bundled install script
|
|
if: matrix.platform == 'windows-latest'
|
|
shell: pwsh
|
|
env:
|
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
|
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
|
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
|
AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
|
|
run: |
|
|
$script = (Resolve-Path 'install.ps1').Path
|
|
studio/src-tauri/windows/sign-with-trusted-signing.ps1 -Path $script
|
|
if ($LASTEXITCODE -ne 0) { Write-Host "::error::could not sign install.ps1 (exit $LASTEXITCODE)"; exit 1 }
|
|
$sig = Get-AuthenticodeSignature $script
|
|
Write-Host "install.ps1 -> $($sig.Status) $($sig.SignerCertificate.Subject)"
|
|
if ($sig.Status -ne 'Valid') { Write-Host "::error::install.ps1 is $($sig.Status) after signing"; exit 1 }
|
|
|
|
- name: Build Windows app
|
|
id: build_windows
|
|
if: matrix.platform == 'windows-latest'
|
|
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
|
|
env:
|
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
|
# The signing key is not password protected, and no secret of this name
|
|
# exists, so this expression has always resolved to the empty string.
|
|
# Stated literally rather than left pointing at a secret that is not
|
|
# there: the value is unchanged, but it no longer reads as configuration
|
|
# someone forgot to set. Empty is not the same as unset here -- tauri
|
|
# prompts for a password when the variable is absent, which would hang
|
|
# the job, so the line stays.
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
|
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
|
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
|
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
|
AZURE_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
|
|
with:
|
|
projectPath: studio
|
|
tauriScript: npx --prefix . tauri
|
|
args: -v ${{ matrix.args }}
|
|
|
|
# ── Windows: scan the signed bundles with Defender before publishing ──
|
|
# 0.1.512-beta shipped a "Trojan:Win32/Wacatac.B!ml" false positive nothing
|
|
# here caught. The runner disables Defender and excludes both drive roots,
|
|
# so restore it and verify the restore took; "!ml" is a cloud verdict, so
|
|
# MAPS must be on; MpCmdRun exit codes are ambiguous, so read the output.
|
|
- name: Scan Windows bundles with Defender
|
|
if: matrix.platform == 'windows-latest'
|
|
# A custom MpCmdRun scan defaults to a one-day timeout.
|
|
timeout-minutes: 20
|
|
shell: pwsh
|
|
env:
|
|
ARTIFACT_PATHS: ${{ steps.build_windows.outputs.artifactPaths }}
|
|
run: |
|
|
$ErrorActionPreference = 'Continue'
|
|
|
|
$atp = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Advanced Threat Protection'
|
|
if (Test-Path $atp) { Remove-ItemProperty -Path $atp -Name 'ForceDefenderPassiveMode' -ErrorAction SilentlyContinue }
|
|
Start-Service -Name WinDefend -ErrorAction SilentlyContinue
|
|
|
|
foreach ($x in @((Get-MpPreference).ExclusionPath)) {
|
|
if ($x) { Remove-MpPreference -ExclusionPath $x -ErrorAction SilentlyContinue }
|
|
}
|
|
Set-MpPreference `
|
|
-DisableRealtimeMonitoring $false -DisableIOAVProtection $false `
|
|
-DisableScriptScanning $false -DisableBehaviorMonitoring $false `
|
|
-DisableArchiveScanning $false -MAPSReporting Advanced `
|
|
-SubmitSamplesConsent SendAllSamples -CloudBlockLevel High `
|
|
-CloudExtendedTimeout 50 -DisableBlockAtFirstSeen $false -ErrorAction Continue
|
|
|
|
$mp = "$env:ProgramFiles\Windows Defender\MpCmdRun.exe"
|
|
$plat = Get-ChildItem 'C:\ProgramData\Microsoft\Windows Defender\Platform' -Directory -ErrorAction SilentlyContinue |
|
|
Sort-Object Name -Descending | Select-Object -First 1
|
|
if ($plat -and (Test-Path "$($plat.FullName)\MpCmdRun.exe")) { $mp = "$($plat.FullName)\MpCmdRun.exe" }
|
|
Update-MpSignature -UpdateSource MicrosoftUpdateServer -ErrorAction Continue
|
|
& $mp -SignatureUpdate 2>&1 | Write-Host
|
|
|
|
# Read these defensively. A runner can come up with the Defender WMI
|
|
# provider dead ("Provider load failure") and the service RPC endpoint
|
|
# down (0x800106ba), which is how the platform incident on 2026-08-06
|
|
# presented: the cmdlets throw, $status/$pref land null, and every check
|
|
# below then reads as a misconfiguration it is not.
|
|
$status = $null
|
|
$pref = $null
|
|
$unavailable = @()
|
|
try { $status = Get-MpComputerStatus -ErrorAction Stop }
|
|
catch { $unavailable += "Get-MpComputerStatus: $($_.Exception.Message)" }
|
|
try { $pref = Get-MpPreference -ErrorAction Stop }
|
|
catch { $unavailable += "Get-MpPreference: $($_.Exception.Message)" }
|
|
if (-not $unavailable -and -not $status) { $unavailable += 'Get-MpComputerStatus returned nothing' }
|
|
if (-not $unavailable -and -not $pref) { $unavailable += 'Get-MpPreference returned nothing' }
|
|
|
|
# Absent scanner vs. bad verdict: only a missing scanner may skip; a
|
|
# misconfigured one or a real detection still fails. Dead cmdlets are not
|
|
# a missing scanner - WMI and service RPC back them, MpCmdRun talks to
|
|
# the engine directly - and conflating the two shipped three releases
|
|
# unscanned since 2026-08-06. Let the EICAR control below decide.
|
|
$cmdletsDown = [bool]$unavailable
|
|
if ($cmdletsDown) {
|
|
Write-Host "::warning::Defender cmdlets are unavailable on this runner ($($unavailable -join '; ')); configuration cannot be read or enforced. Falling back to a direct MpCmdRun scan."
|
|
}
|
|
if ($status) {
|
|
Write-Host "engine $($status.AMEngineVersion), signatures $($status.AntivirusSignatureVersion) ($($status.AntivirusSignatureLastUpdated))"
|
|
}
|
|
|
|
# Set-MpPreference can return cleanly and still be ignored, so verify.
|
|
# Split by whether the gap invalidates the verdict: cloud-delivered
|
|
# protection is inactive without active mode, MAPS and block-at-first-
|
|
# sight, and "!ml" is a cloud verdict, so a clean result without them
|
|
# cannot see what this gate exists to catch. Leftover exclusions only
|
|
# weaken it, since -DisableRemediation ignores file exclusions.
|
|
# Guard each check on the cmdlet that read it, not a blanket flag: the
|
|
# two fail independently (live WMI call vs. registry), so a blanket
|
|
# guard drops a readable $pref's MAPS and block-at-first-sight checks.
|
|
$fatal = @()
|
|
$degraded = @()
|
|
if ($status) {
|
|
if (-not $status.RealTimeProtectionEnabled) { $fatal += "RealTimeProtectionEnabled=false (AMRunningMode=$($status.AMRunningMode))" }
|
|
}
|
|
if ($pref) {
|
|
if ([int]$pref.MAPSReporting -ne 2) { $fatal += "MAPSReporting=$($pref.MAPSReporting), expected 2 (Advanced)" }
|
|
if ($pref.DisableBlockAtFirstSeen) { $fatal += 'DisableBlockAtFirstSeen=true' }
|
|
if ([int]$pref.SubmitSamplesConsent -ne 3) { $degraded += "SubmitSamplesConsent=$($pref.SubmitSamplesConsent), expected 3 (SendAllSamples)" }
|
|
if ([int]$pref.CloudBlockLevel -eq 0) { $degraded += "CloudBlockLevel=$($pref.CloudBlockLevel), expected High" }
|
|
if ($pref.ExclusionPath) { $degraded += "exclusions remain: $($pref.ExclusionPath -join ',')" }
|
|
}
|
|
|
|
# Configuration is not connectivity. Retried, so one network blip does
|
|
# not block a release.
|
|
$maps = $false
|
|
foreach ($try in 1..3) {
|
|
$mapsOut = (& $mp -ValidateMapsConnection 2>&1 | Out-String)
|
|
if ($LASTEXITCODE -eq 0) { $maps = $true; break }
|
|
Write-Host " MAPS check $try failed: $($mapsOut.Trim())"
|
|
Start-Sleep -Seconds (5 * $try)
|
|
}
|
|
# Cmdlets down: losing the cloud degrades rather than blocks the release
|
|
# outright - signature detections still fire, only "!ml" verdicts do not.
|
|
if (-not $maps) {
|
|
if ($cmdletsDown) { $degraded += 'cannot reach the Defender cloud service, so "!ml" cloud verdicts cannot fire' }
|
|
else { $fatal += 'cannot reach the Defender cloud service' }
|
|
}
|
|
|
|
# Positive control: clean and broken-scanner look identical. Split into
|
|
# fragments so this file is not itself a sample.
|
|
$eicarPath = Join-Path $env:RUNNER_TEMP 'defender-selftest.com'
|
|
# Real-time protection may block the write; that is the control passing.
|
|
$controlPassed = $false
|
|
try {
|
|
[System.IO.File]::WriteAllText(
|
|
$eicarPath,
|
|
'X5O!P%@AP[4\PZX54(P^)7CC)7}' + '$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!' + '$H+H*')
|
|
} catch { }
|
|
if (Test-Path $eicarPath) {
|
|
$controlOut = (& $mp -Scan -ScanType 3 -File $eicarPath -DisableRemediation 2>&1 | Out-String)
|
|
$controlPassed = [bool](($controlOut -match 'found\s+\d+\s+threat') -or ($controlOut -match 'EICAR'))
|
|
# Remediation is asynchronous: the sample can pass Test-Path and still
|
|
# be quarantined mid-scan. Vanishing means the control fired, not a
|
|
# missing scanner - nothing else deletes it and -DisableRemediation
|
|
# stops this scan from doing so, so a dead engine leaves it on disk.
|
|
if (-not $controlPassed -and -not (Test-Path $eicarPath)) {
|
|
Write-Host 'EICAR positive control quarantined before the scan could open it; real-time protection is live.'
|
|
$controlPassed = $true
|
|
}
|
|
Remove-Item $eicarPath -Force -ErrorAction SilentlyContinue
|
|
} else {
|
|
Write-Host 'EICAR positive control blocked on write; real-time protection is live.'
|
|
$controlPassed = $true
|
|
}
|
|
# The real test of whether a scanner exists, and the only one left with
|
|
# the cmdlets down: a control that does not fire means MpCmdRun cannot
|
|
# scan either, so skip as before rather than fail on a missing engine.
|
|
if (-not $controlPassed) {
|
|
if ($cmdletsDown) {
|
|
Write-Host "::warning::Defender is unavailable on this runner ($($unavailable -join '; ')) and MpCmdRun could not fire the EICAR positive control either; skipping the bundle scan. This is a missing scanner, not a clean verdict - these bundles were not scanned."
|
|
exit 0
|
|
}
|
|
$fatal += 'EICAR positive control did not fire'
|
|
}
|
|
if ($cmdletsDown) {
|
|
Write-Host 'EICAR positive control fired via MpCmdRun; scanning with the cmdlets unavailable.'
|
|
}
|
|
|
|
if ($degraded) {
|
|
Write-Host "::warning::Defender is not fully configured on this runner ($($degraded -join '; ')). Scanning anyway."
|
|
}
|
|
if ($fatal) {
|
|
Write-Host "::error::Defender cannot give an authoritative verdict here ($($fatal -join '; ')); refusing to publish on a scan that cannot see the detection class this gate exists for"
|
|
exit 1
|
|
}
|
|
|
|
# Fatal, not a warning: exiting 0 here would pass an unscanned release.
|
|
if ([string]::IsNullOrWhiteSpace($env:ARTIFACT_PATHS)) {
|
|
Write-Host '::error::tauri-action produced no artifactPaths output; refusing to publish unscanned bundles'
|
|
exit 1
|
|
}
|
|
try {
|
|
$paths = @($env:ARTIFACT_PATHS | ConvertFrom-Json)
|
|
} catch {
|
|
Write-Host "::error::could not parse artifactPaths ($($_.Exception.Message)); refusing to publish unscanned bundles"
|
|
exit 1
|
|
}
|
|
$missing = @($paths | Where-Object { $_ -and -not (Test-Path $_ -PathType Leaf) })
|
|
if ($missing) {
|
|
Write-Host "::error::artifactPaths lists files that do not exist: $($missing -join ', ')"
|
|
exit 1
|
|
}
|
|
$paths = @($paths | Where-Object { $_ })
|
|
if (-not $paths) {
|
|
Write-Host '::error::artifactPaths resolved to an empty list; refusing to publish unscanned bundles'
|
|
exit 1
|
|
}
|
|
Write-Host "scanning $($paths.Count) Windows bundle(s)"
|
|
|
|
# Scan copies: real-time protection is live, so a detection would delete
|
|
# the release artifact itself. Copies double as submission material,
|
|
# since clearance is per hash and a rebuild hashes differently.
|
|
$work = Join-Path $env:RUNNER_TEMP 'defender-scan'
|
|
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
|
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
|
|
|
# Real-time protection acts on open and on write, and -DisableRemediation
|
|
# governs only the explicit scan, so a hit would quarantine the bundle
|
|
# mid-copy and leave the failure handler nothing to upload. Exempt both
|
|
# ends of the copy. The verdict is unaffected: -DisableRemediation
|
|
# ignores file exclusions. Left in place, since the copies must survive
|
|
# until the failure-gated preserve step runs.
|
|
$exclude = @($work) + @($paths | Split-Path -Parent | Sort-Object -Unique)
|
|
Add-MpPreference -ExclusionPath $exclude -ErrorAction Continue
|
|
|
|
# -ErrorAction Continue does not stop on a rejected exclusion, so read
|
|
# it back. Defender stores these verbatim; normalise anyway.
|
|
# Keyed on the reading cmdlet, not the blanket flag: $pref can be live
|
|
# while $status is down. With $pref unreadable there is nothing to read
|
|
# back, and a mid-scan quarantine still fails below as unscannable.
|
|
if ($pref) {
|
|
$norm = { param($p) $p.TrimEnd('\').ToLowerInvariant() }
|
|
$applied = @(@((Get-MpPreference).ExclusionPath) | ForEach-Object { & $norm $_ })
|
|
$notApplied = @($exclude | Where-Object { $applied -notcontains (& $norm $_) })
|
|
if ($notApplied) {
|
|
Write-Host "::error::Defender refused these exclusions: $($notApplied -join ', '); a detection would quarantine the bundle mid-copy and leave nothing to submit"
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
$detected = @()
|
|
$unscanned = @()
|
|
foreach ($f in $paths) {
|
|
$leaf = Split-Path $f -Leaf
|
|
$copy = Join-Path $work $leaf
|
|
Copy-Item $f $copy -Force
|
|
|
|
# Block-at-first-sight only consults the cloud for internet-zone files,
|
|
# so stamp mark-of-the-web or this is not the user path.
|
|
"[ZoneTransfer]`nZoneId=3" | Set-Content -Path $copy -Stream Zone.Identifier -Encoding Ascii -ErrorAction SilentlyContinue
|
|
|
|
$out = & $mp -Scan -ScanType 3 -File $copy -DisableRemediation 2>&1
|
|
$code = $LASTEXITCODE
|
|
$text = ($out | Out-String)
|
|
$out | ForEach-Object { Write-Host " $_" }
|
|
|
|
# Exit 2 is a detection or a scan error, and 0 is the only documented
|
|
# clean code, so read the output text too.
|
|
if ($text -match 'found\s+\d+\s+threat') {
|
|
$names = @([regex]::Matches($text, '((?:Trojan|Virus|Worm|Backdoor|Ransom|HackTool|PUA|Behavior|Program|Misleading)[:\w\./!\-]+)') |
|
|
ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
|
|
if (-not $names) { $names = @('unnamed detection') }
|
|
Write-Host "::error::Defender flagged ${leaf}: $($names -join ', ')"
|
|
$detected += "$leaf [$($names -join ', ')]"
|
|
} elseif ($code -ne 0) {
|
|
Write-Host "::error::Defender did not complete a scan of $leaf (MpCmdRun exit $code)"
|
|
$unscanned += "$leaf [exit $code]"
|
|
} else {
|
|
Write-Host "clean: $leaf"
|
|
}
|
|
}
|
|
|
|
if ($unscanned) {
|
|
Write-Host "::error::Refusing to publish bundles Defender could not scan: $($unscanned -join '; ')"
|
|
}
|
|
if ($detected) {
|
|
Write-Host "::error::Refusing to publish a Windows bundle Defender flags: $($detected -join '; ')"
|
|
Write-Host 'If this is a false positive, submit it at https://www.microsoft.com/en-us/wdsi/filesubmission (Software developer -> incorrectly detected) and re-run once cleared.'
|
|
}
|
|
if ($detected -or $unscanned) { exit 1 }
|
|
Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue
|
|
Write-Host "All $($paths.Count) Windows bundle(s) scanned clean."
|
|
|
|
# Later steps carry an implicit success(), so on a detection the flagged
|
|
# bundle would be discarded with the runner.
|
|
- name: Preserve flagged Windows bundles
|
|
if: failure() && matrix.platform == 'windows-latest'
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: flagged-windows-bundles-${{ matrix.artifact }}
|
|
path: ${{ runner.temp }}/defender-scan/*
|
|
if-no-files-found: ignore
|
|
compression-level: 0
|
|
retention-days: 7
|
|
|
|
# ── Windows: every executable in the bundle must be signed ──
|
|
# NSIS runs its plugin DLLs from $PLUGINSDIR; signing the installer misses them.
|
|
- name: Verify every executable in the Windows bundles is signed
|
|
if: matrix.platform == 'windows-latest'
|
|
shell: pwsh
|
|
env:
|
|
ARTIFACT_PATHS: ${{ steps.build_windows.outputs.artifactPaths }}
|
|
run: |
|
|
$paths = @($env:ARTIFACT_PATHS | ConvertFrom-Json) |
|
|
Where-Object { $_ -like '*-setup.exe' }
|
|
if (-not $paths) { Write-Host '::error::no Windows installer to verify'; exit 1 }
|
|
.github/scripts/assert-bundle-signed.ps1 -Path $paths
|
|
|
|
# ── Windows: assemble a Microsoft false-positive submission packet ──
|
|
# The WDSI portal is a web form with no API, so this cannot be automated,
|
|
# but pre-filling the lookups makes submitting a build take seconds.
|
|
- name: Prepare Microsoft submission packet
|
|
if: (success() || failure()) && matrix.platform == 'windows-latest' && steps.build_windows.outcome == 'success'
|
|
shell: pwsh
|
|
env:
|
|
ARTIFACT_PATHS: ${{ steps.build_windows.outputs.artifactPaths }}
|
|
run: |
|
|
$paths = @($env:ARTIFACT_PATHS | ConvertFrom-Json) |
|
|
Where-Object { $_ -like '*-setup.exe' }
|
|
$md = @(
|
|
'## Microsoft submission packet',
|
|
'',
|
|
'Submit at <https://www.microsoft.com/en-us/wdsi/filesubmission> before announcing this release.',
|
|
'Choose **Software developer** -> **Incorrectly detected as malware/malicious**.',
|
|
'The portal caps uploads at 50 MB. Clearance is per hash, so never substitute a',
|
|
'different file: for a larger bundle use <https://security.microsoft.com/reportsubmission>,',
|
|
'which takes 500 MB.',
|
|
''
|
|
)
|
|
foreach ($f in $paths) {
|
|
$item = Get-Item $f
|
|
$sig = Get-AuthenticodeSignature $f
|
|
$md += "### $($item.Name)"
|
|
$md += ''
|
|
$md += '| field | value |'
|
|
$md += '|---|---|'
|
|
$md += "| SHA-256 | ``$((Get-FileHash -Algorithm SHA256 $f).Hash)`` |"
|
|
$md += "| SHA-1 | ``$((Get-FileHash -Algorithm SHA1 $f).Hash)`` |"
|
|
$md += "| Size | $($item.Length) bytes |"
|
|
$md += "| Signature | $($sig.Status) |"
|
|
$md += "| Signer | $($sig.SignerCertificate.Subject) |"
|
|
$md += "| Product | $($env:APP_VERSION) |"
|
|
$md += ''
|
|
}
|
|
$md += '> Clearance is per hash. It fixes the release you submit and nothing after it,'
|
|
$md += '> so this is a complement to signing, not a substitute.'
|
|
$out = "$env:RUNNER_TEMP\ms-submission-packet.md"
|
|
$md -join "`n" | Out-File $out -Encoding utf8
|
|
$md -join "`n" | Out-File $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
|
Write-Host ($md -join "`n")
|
|
|
|
- name: Stage release assets
|
|
shell: bash
|
|
env:
|
|
ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths || steps.build_macos.outputs.artifactPaths || steps.build_windows.outputs.artifactPaths }}
|
|
run: |
|
|
set -euo pipefail
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
PYTHON=python3
|
|
else
|
|
PYTHON=python
|
|
fi
|
|
"$PYTHON" <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import sys
|
|
|
|
raw_paths = os.environ.get('ARTIFACT_PATHS', '')
|
|
try:
|
|
artifact_paths = json.loads(raw_paths)
|
|
except json.JSONDecodeError as error:
|
|
sys.exit(f'Invalid tauri-action artifactPaths output: {error}')
|
|
if not isinstance(artifact_paths, list) or not artifact_paths:
|
|
sys.exit('tauri-action did not return any release artifacts')
|
|
|
|
destination = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
base_name = f'Unsloth-Desktop-{os.environ["ASSET_VERSION"]}'
|
|
suffix_names = (
|
|
('.app.tar.gz.sig', f'{base_name}-ARM64.app.tar.gz.sig'),
|
|
('.app.tar.gz', f'{base_name}-ARM64.app.tar.gz'),
|
|
('.AppImage.sig', f'{base_name}-Linux.AppImage.sig'),
|
|
('.AppImage', f'{base_name}-Linux.AppImage'),
|
|
('-setup.exe.sig', f'{base_name}-Windows.exe.sig'),
|
|
('-setup.exe', f'{base_name}-Windows.exe'),
|
|
('.dmg', f'{base_name}-MacOS.dmg'),
|
|
('.deb', f'{base_name}-Ubuntu.deb'),
|
|
)
|
|
staged = []
|
|
for raw_path in artifact_paths:
|
|
source = pathlib.Path(raw_path)
|
|
if not source.is_file():
|
|
continue
|
|
target_name = next(
|
|
(target_name for suffix, target_name in suffix_names if source.name.endswith(suffix)),
|
|
None,
|
|
)
|
|
if target_name is None:
|
|
print(f'Skipping non-release Tauri artifact: {source.name}')
|
|
continue
|
|
target = destination / target_name
|
|
if target.exists():
|
|
sys.exit(f'Duplicate staged release asset name: {target_name}')
|
|
shutil.copy2(source, target)
|
|
staged.append(target_name)
|
|
|
|
if not staged:
|
|
sys.exit('No release files were staged')
|
|
print('Staged release assets:')
|
|
print('\n'.join(sorted(staged)))
|
|
PY
|
|
|
|
- name: Upload signed release assets
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
with:
|
|
name: desktop-release-${{ matrix.artifact }}
|
|
path: ${{ runner.temp }}/desktop-release-assets/*
|
|
if-no-files-found: error
|
|
compression-level: 0
|
|
retention-days: 1
|
|
|
|
# Only this job gets write access; builds hand off signed files via artifacts.
|
|
# Draft runs stop after uploading immutable assets; publishing the reviewed draft
|
|
# is validated by publish-desktop-updater.yml without rebuilding.
|
|
publish-release:
|
|
name: Publish desktop release
|
|
# Only prepare-version, so this starts alongside the build matrix and
|
|
# queues for its runner during the build instead of after it. "Wait for the
|
|
# build matrix" below restores the gate `needs: build` gave away. The cost
|
|
# is one runner slot held for the length of the run.
|
|
needs: [prepare-version]
|
|
runs-on: ubuntu-latest
|
|
# Waits out the build (legs cap at 60) then downloads and uploads; the
|
|
# publish steps themselves are 17-40s.
|
|
timeout-minutes: 90
|
|
permissions:
|
|
contents: write # create the versioned Release and replace updater-channel metadata
|
|
actions: read # the waiter reads this run's job list
|
|
env:
|
|
GH_REPO: ${{ github.repository }}
|
|
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
|
ASSET_VERSION: ${{ needs.prepare-version.outputs.asset_version }}
|
|
PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
|
|
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
|
|
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
|
|
|
steps:
|
|
- name: Harden runner (audit)
|
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
|
with:
|
|
egress-policy: audit
|
|
|
|
# Replaces the `needs: build` gate dropped above, and must be at least as
|
|
# strict: publishing a partial release is far worse than publishing late.
|
|
- name: Wait for the build matrix
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -uo pipefail
|
|
|
|
# The three matrix legs, by their `name:` (Build <matrix.label>).
|
|
LEGS=('Build macOS (Apple Silicon)' 'Build Linux (x64)' 'Build Windows (x64)')
|
|
POLL=30
|
|
DEADLINE=$(( $(date +%s) + 75 * 60 ))
|
|
# Matrix job records appear with the run, so a leg still missing well
|
|
# after the start is a real problem, not a slow API.
|
|
STARTUP_DEADLINE=$(( $(date +%s) + 15 * 60 ))
|
|
API_FAILS=0
|
|
|
|
while :; do
|
|
if ! JOBS="$(gh api --paginate \
|
|
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?per_page=100" \
|
|
--jq '.jobs[] | [.name, .status, (.conclusion // "none")] | @tsv' 2>/dev/null)"; then
|
|
# A blip in the jobs API must not throw away a finished build.
|
|
API_FAILS=$(( API_FAILS + 1 ))
|
|
if [ "$API_FAILS" -ge 10 ]; then
|
|
echo "ERROR: jobs API failed 10 times running; cannot confirm the build matrix finished" >&2
|
|
exit 1
|
|
fi
|
|
echo "jobs API call failed (${API_FAILS}/10); retrying in ${POLL}s"
|
|
sleep "$POLL"
|
|
continue
|
|
fi
|
|
API_FAILS=0
|
|
|
|
MISSING=""
|
|
PENDING=0
|
|
BAD=""
|
|
for leg in "${LEGS[@]}"; do
|
|
ROW="$(printf '%s\n' "$JOBS" | awk -F'\t' -v n="$leg" '$1 == n {print; exit}')"
|
|
if [ -z "$ROW" ]; then
|
|
MISSING="${MISSING} '${leg}'"
|
|
continue
|
|
fi
|
|
STATUS="$(printf '%s' "$ROW" | cut -f2)"
|
|
CONCL="$(printf '%s' "$ROW" | cut -f3)"
|
|
if [ "$STATUS" != "completed" ]; then
|
|
PENDING=$(( PENDING + 1 ))
|
|
elif [ "$CONCL" != "success" ]; then
|
|
BAD="${BAD}${leg} (${CONCL}); "
|
|
fi
|
|
done
|
|
|
|
# Fail on the first finished leg that did not succeed rather than
|
|
# holding a runner to reach the same answer later.
|
|
if [ -n "$BAD" ]; then
|
|
echo "ERROR: refusing to publish, these build jobs did not succeed: ${BAD}" >&2
|
|
exit 1
|
|
fi
|
|
if [ -n "$MISSING" ]; then
|
|
if [ "$(date +%s)" -gt "$STARTUP_DEADLINE" ]; then
|
|
echo "ERROR: no job record for:${MISSING}; refusing to publish without confirming they ran" >&2
|
|
exit 1
|
|
fi
|
|
echo "waiting for job records to appear for:${MISSING}"
|
|
sleep "$POLL"
|
|
continue
|
|
fi
|
|
if [ "$PENDING" -eq 0 ]; then
|
|
echo "all ${#LEGS[@]} build legs finished and succeeded"
|
|
break
|
|
fi
|
|
if [ "$(date +%s)" -gt "$DEADLINE" ]; then
|
|
echo "ERROR: timed out waiting for the build matrix; ${PENDING} leg(s) still running" >&2
|
|
exit 1
|
|
fi
|
|
echo "${PENDING} of ${#LEGS[@]} build legs still running; polling again in ${POLL}s"
|
|
sleep "$POLL"
|
|
done
|
|
|
|
- name: Download signed release assets
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: desktop-release-*
|
|
path: ${{ runner.temp }}/desktop-release-assets
|
|
merge-multiple: true
|
|
|
|
- name: Validate release asset set
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
python3 <<'PY'
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
|
files = [path for path in asset_dir.iterdir() if path.is_file()]
|
|
base_name = f'Unsloth-Desktop-{os.environ["ASSET_VERSION"]}'
|
|
expected_names = {
|
|
f'{base_name}-MacOS.dmg',
|
|
f'{base_name}-ARM64.app.tar.gz',
|
|
f'{base_name}-ARM64.app.tar.gz.sig',
|
|
f'{base_name}-Linux.AppImage',
|
|
f'{base_name}-Linux.AppImage.sig',
|
|
f'{base_name}-Ubuntu.deb',
|
|
f'{base_name}-Windows.exe',
|
|
f'{base_name}-Windows.exe.sig',
|
|
}
|
|
actual_names = {path.name for path in files}
|
|
if actual_names != expected_names:
|
|
missing = sorted(expected_names - actual_names)
|
|
unexpected = sorted(actual_names - expected_names)
|
|
sys.exit(
|
|
'Desktop release asset names do not match the branding contract: '
|
|
f'missing={missing}, unexpected={unexpected}'
|
|
)
|
|
print('\n'.join(sorted(actual_names)))
|
|
PY
|
|
|
|
# Validation runs before the scan so an already-used version is rejected
|
|
# before any bundle is disclosed to VirusTotal. Creation remains deferred
|
|
# until after the scan so a non-draft release is never left public and empty.
|
|
- name: Validate versioned release state
|
|
id: versioned_release_state
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
notes_file="$RUNNER_TEMP/desktop-release-notes.md"
|
|
printf '%s\n' "$DESKTOP_RELEASE_NOTES" > "$notes_file"
|
|
|
|
# The v{version} release already exists; re-checked here because the
|
|
# read-scoped prepare-version token cannot see drafts.
|
|
if ! gh api "repos/${GH_REPO}/releases/tags/${DESKTOP_RELEASE_TAG}" \
|
|
> "$RUNNER_TEMP/target-release-publish.json" 2>/dev/null; then
|
|
echo "Release ${DESKTOP_RELEASE_TAG} does not exist." >&2
|
|
echo " Tag main and publish it first, then re-dispatch this workflow." >&2
|
|
exit 1
|
|
fi
|
|
# A carried-forward manifest for an older version is expected here (see
|
|
# publish-desktop-updater.yml), so read it rather than judge by name alone.
|
|
mkdir -p "$RUNNER_TEMP/target-manifest"
|
|
gh release download "$DESKTOP_RELEASE_TAG" --pattern latest.json \
|
|
--dir "$RUNNER_TEMP/target-manifest" --clobber 2>/dev/null || true
|
|
python3 <<'PY'
|
|
import json, os, pathlib, sys
|
|
|
|
runner = pathlib.Path(os.environ['RUNNER_TEMP'])
|
|
release = json.loads((runner / 'target-release-publish.json').read_text())
|
|
tag = os.environ['DESKTOP_RELEASE_TAG']
|
|
# Checked here, not at promotion: GitHub refuses to mark a prerelease
|
|
# latest, and by then the bundles are already public.
|
|
if release.get('prerelease'):
|
|
sys.exit(f'Release {tag} is a prerelease; GitHub cannot mark it latest.')
|
|
|
|
prefix = f'Unsloth-Desktop-{os.environ["ASSET_VERSION"]}'
|
|
clashing = sorted(
|
|
asset['name']
|
|
for asset in release.get('assets', [])
|
|
if str(asset.get('name', '')).startswith(prefix)
|
|
)
|
|
manifest_path = runner / 'target-manifest' / 'latest.json'
|
|
if manifest_path.is_file():
|
|
try:
|
|
version = json.loads(manifest_path.read_text()).get('version')
|
|
except json.JSONDecodeError:
|
|
version = None
|
|
# Only this version's own manifest is a clash; anything else is the
|
|
# pointer being carried forward and is overwritten on upload.
|
|
if not isinstance(version, str) or version.removeprefix('v') == tag.removeprefix('v'):
|
|
clashing.append('latest.json')
|
|
|
|
if clashing:
|
|
print(f'Refusing to republish {tag}: it already carries desktop assets.', file=sys.stderr)
|
|
for name in clashing:
|
|
print(f' gh release delete-asset {tag} {name} --yes', file=sys.stderr)
|
|
sys.exit(1)
|
|
PY
|
|
|
|
- name: Generate versioned updater metadata
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
python3 <<'PY'
|
|
import base64
|
|
import binascii
|
|
import datetime
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
import urllib.parse
|
|
|
|
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
|
files = [path for path in asset_dir.iterdir() if path.is_file()]
|
|
|
|
def exactly_one(suffix: str) -> pathlib.Path:
|
|
matches = [path for path in files if path.name.endswith(suffix)]
|
|
if len(matches) != 1:
|
|
sys.exit(f'Expected exactly one {suffix} updater asset, found {len(matches)}')
|
|
return matches[0]
|
|
|
|
def entry(signature_suffix: str) -> dict[str, str]:
|
|
signature_path = exactly_one(signature_suffix)
|
|
bundle_name = signature_path.name.removesuffix('.sig')
|
|
bundle_path = asset_dir / bundle_name
|
|
if not bundle_path.is_file():
|
|
sys.exit(f'Missing updater bundle for {signature_path.name}: {bundle_name}')
|
|
signature_value = signature_path.read_text(encoding='utf-8').strip()
|
|
try:
|
|
decoded_signature = base64.b64decode(signature_value, validate=True)
|
|
except (binascii.Error, ValueError):
|
|
sys.exit(f'Invalid base64 updater signature: {signature_path.name}')
|
|
if not decoded_signature.startswith(b'untrusted comment:') or b'\ntrusted comment:' not in decoded_signature:
|
|
sys.exit(f'Invalid minisign updater signature: {signature_path.name}')
|
|
encoded_tag = urllib.parse.quote(os.environ['DESKTOP_RELEASE_TAG'], safe='')
|
|
encoded_name = urllib.parse.quote(bundle_name, safe='')
|
|
return {
|
|
'signature': signature_value,
|
|
'url': (
|
|
f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/'
|
|
f'{encoded_tag}/{encoded_name}'
|
|
),
|
|
}
|
|
|
|
darwin = entry('.app.tar.gz.sig')
|
|
linux = entry('.AppImage.sig')
|
|
windows = entry('.exe.sig')
|
|
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
|
|
metadata = {
|
|
'version': os.environ['APP_VERSION'],
|
|
# App version is SemVer; this is the backend release it pins.
|
|
'pypi_version': os.environ['PYPI_VERSION'],
|
|
'notes': notes,
|
|
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
|
'platforms': {
|
|
'darwin-aarch64': darwin,
|
|
'darwin-aarch64-app': darwin,
|
|
'linux-x86_64': linux,
|
|
'linux-x86_64-appimage': linux,
|
|
'windows-x86_64': windows,
|
|
'windows-x86_64-nsis': windows,
|
|
},
|
|
}
|
|
output = pathlib.Path(os.environ['RUNNER_TEMP'], 'latest.json')
|
|
output.write_text(json.dumps(metadata, indent=2) + '\n')
|
|
PY
|
|
|
|
# The release body is the maintainer's changelog and this workflow never
|
|
# rewrites it. The target release is already public, so a validation-only
|
|
# run must not upload to it.
|
|
- name: Publish versioned release assets
|
|
if: ${{ !inputs.draft }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
release_assets=()
|
|
for asset in "$RUNNER_TEMP/desktop-release-assets"/*; do
|
|
[[ "$asset" == *.sig ]] || release_assets+=("$asset")
|
|
done
|
|
# Preserve the published digests by refusing existing asset names.
|
|
gh release upload "$DESKTOP_RELEASE_TAG" "${release_assets[@]}"
|
|
|
|
- name: Publish versioned updater metadata
|
|
if: ${{ !inputs.draft }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
# --clobber only for latest.json: it may already hold an older version's
|
|
# pointer. The bundles above are still uploaded without it.
|
|
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/latest.json" --clobber
|
|
|
|
- name: Download versioned updater metadata
|
|
if: ${{ !inputs.draft }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p "$RUNNER_TEMP/desktop-updater"
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${DESKTOP_RELEASE_TAG}" > "$RUNNER_TEMP/source-release.json"
|
|
python3 <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
source = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'source-release.json').read_text())
|
|
expected_tag = os.environ['DESKTOP_RELEASE_TAG']
|
|
if source.get('tag_name') != expected_tag:
|
|
sys.exit(f'Expected source release {expected_tag}, got {source.get("tag_name")}')
|
|
if source.get('draft'):
|
|
sys.exit(f'Source desktop release {expected_tag} is draft; refusing to publish public updater channel')
|
|
PY
|
|
gh release download "$DESKTOP_RELEASE_TAG" --pattern latest.json --dir "$RUNNER_TEMP/desktop-updater" --clobber
|
|
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
|
|
|
|
- name: Validate versioned updater metadata
|
|
if: ${{ !inputs.draft }}
|
|
shell: bash
|
|
run: |
|
|
python3 <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
app_version = os.environ['APP_VERSION']
|
|
release_tag = os.environ['DESKTOP_RELEASE_TAG']
|
|
latest_path = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-updater', 'latest.json')
|
|
data = json.loads(latest_path.read_text())
|
|
if not isinstance(data, dict):
|
|
sys.exit('latest.json must be a JSON object')
|
|
|
|
version = data.get('version')
|
|
if not isinstance(version, str) or not version:
|
|
sys.exit('latest.json missing version')
|
|
if not re.fullmatch(r'v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', version):
|
|
sys.exit(f'latest.json version is not SemVer-like: {version}')
|
|
if version.removeprefix('v') != app_version:
|
|
sys.exit(f'latest.json version {version} does not match desktop app version {app_version}')
|
|
|
|
platforms = data.get('platforms')
|
|
if not isinstance(platforms, dict) or not platforms:
|
|
sys.exit('latest.json missing platforms')
|
|
|
|
required_families = {
|
|
'darwin-aarch64': False,
|
|
'linux-x86_64': False,
|
|
'windows-x86_64': False,
|
|
}
|
|
expected_prefix = f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/{release_tag}/'
|
|
forbidden_fragments = ('/releases/latest/', '/releases/download/desktop-latest/')
|
|
|
|
for platform, entry in platforms.items():
|
|
if not isinstance(entry, dict):
|
|
sys.exit(f'Platform {platform} must be an object')
|
|
url = entry.get('url')
|
|
signature = entry.get('signature')
|
|
if not isinstance(url, str) or not url.strip():
|
|
sys.exit(f'Platform {platform} missing url')
|
|
if not isinstance(signature, str) or not signature.strip():
|
|
sys.exit(f'Platform {platform} missing signature')
|
|
if any(fragment in url for fragment in forbidden_fragments):
|
|
sys.exit(f'Platform {platform} points at a moving updater channel: {url}')
|
|
if not url.startswith(expected_prefix):
|
|
sys.exit(f'Platform {platform} URL must point at {release_tag}: {url}')
|
|
for family in required_families:
|
|
if platform == family or platform.startswith(family + '-'):
|
|
required_families[family] = True
|
|
|
|
missing = [family for family, found in required_families.items() if not found]
|
|
if missing:
|
|
sys.exit('latest.json missing required platform families: ' + ', '.join(missing))
|
|
PY
|
|
|
|
- name: Promote normal release to GitHub latest
|
|
if: ${{ !inputs.draft }}
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
set -euo pipefail
|
|
release_json="$RUNNER_TEMP/source-release.json"
|
|
release_id="$(python3 - "$release_json" <<'PY'
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
|
|
release = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
if release.get('draft'):
|
|
sys.exit('Refusing to promote a draft release')
|
|
if release.get('prerelease'):
|
|
sys.exit('Desktop releases must be normal GitHub releases, not prereleases')
|
|
release_id = release.get('id')
|
|
if not isinstance(release_id, int):
|
|
sys.exit('Published desktop release is missing its GitHub release id')
|
|
print(release_id)
|
|
PY
|
|
)"
|
|
|
|
# The prepare-version downgrade check ran before a build that can take an
|
|
# hour, so re-read GitHub latest here: promoting past a newer release would
|
|
# hand every client an older manifest.
|
|
# Fail closed: a transient lookup failure must not read as "no newer
|
|
# release". Only a genuine 404 means the repository has no latest yet.
|
|
latest_before="$RUNNER_TEMP/latest-before-promote.json"
|
|
latest_error="$RUNNER_TEMP/latest-before-promote.err"
|
|
if gh api "repos/${GITHUB_REPOSITORY}/releases/latest" > "$latest_before" 2> "$latest_error"; then
|
|
python3 - "$latest_before" <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
|
|
semver = re.compile(r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$')
|
|
|
|
def identifier(value):
|
|
# beta10 must sort above beta2, so a trailing number compares as one.
|
|
if value.isdigit():
|
|
return (0, '', int(value))
|
|
tail = re.fullmatch(r'([A-Za-z-]+)(\d+)', value)
|
|
if tail:
|
|
return (1, tail.group(1).lower(), int(tail.group(2)))
|
|
return (1, value, -1)
|
|
|
|
def key(tag):
|
|
match = semver.fullmatch(tag or '')
|
|
if not match:
|
|
return None
|
|
major, minor, patch, pre = match.groups()
|
|
# A prerelease sorts below the same release without one.
|
|
return (
|
|
int(major), int(minor), int(patch), 0 if pre else 1,
|
|
[identifier(part) for part in pre.split('.')] if pre else [],
|
|
)
|
|
|
|
current = json.loads(pathlib.Path(sys.argv[1]).read_text()).get('tag_name')
|
|
candidate = os.environ['DESKTOP_RELEASE_TAG']
|
|
current_key, candidate_key = key(current), key(candidate)
|
|
if current_key and candidate_key and current_key > candidate_key:
|
|
sys.exit(f'Refusing to promote {candidate}: GitHub latest advanced to {current}.')
|
|
PY
|
|
elif grep -Fq '(HTTP 404)' "$latest_error"; then
|
|
echo "No GitHub latest release yet; nothing to downgrade."
|
|
else
|
|
echo "Could not read GitHub latest; refusing to promote." >&2
|
|
cat "$latest_error" >&2
|
|
exit 1
|
|
fi
|
|
|
|
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \
|
|
-f make_latest=true > "$RUNNER_TEMP/promoted-release.json"
|
|
gh api "repos/${GITHUB_REPOSITORY}/releases/latest" > "$RUNNER_TEMP/latest-release.json"
|
|
python3 - "$RUNNER_TEMP/latest-release.json" <<'PY'
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sys
|
|
|
|
latest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
|
expected = os.environ['DESKTOP_RELEASE_TAG']
|
|
if latest.get('tag_name') != expected:
|
|
sys.exit(f'GitHub latest release is {latest.get("tag_name")}, expected {expected}')
|
|
if latest.get('draft') or latest.get('prerelease'):
|
|
sys.exit(f'GitHub latest release {expected} is not a normal published release')
|
|
names = {asset.get('name') for asset in latest.get('assets', [])}
|
|
if 'latest.json' not in names:
|
|
sys.exit(f'GitHub latest release {expected} is missing latest.json')
|
|
PY
|
|
|
|
|
|
# ── Advisory multi-engine scan of the bundles just uploaded ──
|
|
# Defender (build job) is one vendor and gates only Windows. VirusTotal
|
|
# aggregates ~70 engines across all four bundles, which is how a false
|
|
# positive from some other vendor gets noticed here rather than in a user bug
|
|
# report. Advisory on purpose: installers routinely trip one or two obscure
|
|
# heuristics, so a hard gate would block releases on noise. Defender stays the
|
|
# authoritative fail-closed check.
|
|
#
|
|
# Its own job, after publish-release, because the scan is rate-limit bound and
|
|
# not parallelisable: the free tier allows 4 requests/minute per key, so the
|
|
# four bundles share one budget however they are scheduled. Inside the publish
|
|
# job it was ~9 minutes of the ~9.5 the job took, delaying every release by its
|
|
# full length while being unable to block one.
|
|
virustotal-scan:
|
|
# Not "post-publish": `inputs.draft` defaults to true and the release is
|
|
# created with `--draft` on that input, so the ordinary run has uploaded the
|
|
# assets to a draft. The name has to read true for both dispatches.
|
|
name: VirusTotal release asset scan
|
|
needs: [publish-release]
|
|
runs-on: ubuntu-latest
|
|
# A missing secret, a VirusTotal outage or quota exhaustion must not fail
|
|
# the release whose assets are already uploaded.
|
|
continue-on-error: true
|
|
timeout-minutes: 30
|
|
steps:
|
|
- name: Harden runner (audit)
|
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
|
with:
|
|
egress-policy: audit
|
|
|
|
# Sparse: a full checkout to fetch one file would be wasteful.
|
|
- name: Check out the scan script
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
persist-credentials: false
|
|
sparse-checkout: scripts/virustotal_scan.py
|
|
sparse-checkout-cone-mode: false
|
|
|
|
- name: Download published assets
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
pattern: desktop-release-*
|
|
path: ${{ runner.temp }}/desktop-release-assets
|
|
merge-multiple: true
|
|
|
|
- name: VirusTotal scan
|
|
env:
|
|
VT_API_KEY: ${{ secrets.VIRUS_TOTAL_API_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Name the cause loudly: a silent failure here leaves the release
|
|
# scanned by nothing but a terse "no summary" note.
|
|
if [ ! -f scripts/virustotal_scan.py ]; then
|
|
echo "::error::scripts/virustotal_scan.py is missing; the checkout step in this job must provide it"
|
|
exit 1
|
|
fi
|
|
python3 scripts/virustotal_scan.py \
|
|
"$RUNNER_TEMP/desktop-release-assets" \
|
|
--output-markdown "$RUNNER_TEMP/virustotal-summary.md"
|
|
|
|
- name: Publish VirusTotal summary
|
|
if: always()
|
|
run: |
|
|
set -euo pipefail
|
|
summary="$RUNNER_TEMP/virustotal-summary.md"
|
|
if [ -f "$summary" ]; then
|
|
cat "$summary" >> "$GITHUB_STEP_SUMMARY"
|
|
else
|
|
# Must stay byte-identical to SUMMARY_HEADING in
|
|
# scripts/virustotal_scan.py, or a run that produced no summary shows
|
|
# a second, unrelated section. A test ties the two together.
|
|
echo '### VirusTotal release asset scan' >> "$GITHUB_STEP_SUMMARY"
|
|
echo '' >> "$GITHUB_STEP_SUMMARY"
|
|
echo 'The scan step produced no summary.' >> "$GITHUB_STEP_SUMMARY"
|
|
fi
|