ci(release): stop the macOS preview updater bundle colliding with itself (#1281)

* fix(engines): show the under-provisioned-VRAM warning instead of discarding it

Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277
on 6 GB) share one shape: the user generates, waits out the entire 300s
compute budget, and is then told the job "was too heavy for the available
compute".

The warning existed the whole time. Routing computes it (#1226's `_caveat`:
"…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow
generations that may time out"), and `/engines/select` echoes it in
`routing_reason` — but notifyEngineSelected only surfaced a reason when
`routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED
verdict, so it fell through to the green "switched" success toast and was
thrown away. The user was told everything was fine, then waited five minutes
to find out it wasn't.

Now any caveat on the echo raises a warn-tone toast naming it, with a longer
duration since it lists the ways around the limit. This covers the kernel-risk
caveat on the same path.

Deliberately still ADVISORY, not blocking — matching the routing layer's
documented contract (the driver can page to system RAM, and short inputs fit
where long ones don't). The engine is still selected; the user just finds out
now instead of after the timeout. This is the first-run path too: the wizard's
library step shares notifyEngineSelected.

Fail-before verified: both new tests fail against the previous version.

Known remaining gap: a user whose engine is already selected sees this only
when they re-pick. A generate-time preflight would close that, but it needs a
"once per session, not per generate" design — filed as follow-up rather than
guessed at here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(release): stop the macOS preview updater bundle colliding with itself

The nightly preview run has failed on both macOS legs since early July:

  Uploading OmniVoice Studio_x64.app.tar.gz...
  ##[error]Validation Failed: {"resource":"ReleaseAsset",
                               "code":"already_exists","field":"name"}

`preview` is a ROLLING release, reused every night, and macOS updater
artifacts are the only ones Tauri names without the version:

  OmniVoice Studio_0.4.1-103_x64.dmg   unique per run — uploads fine
  OmniVoice Studio_x64.app.tar.gz      constant — collides on run 2+

Consequences, verified against the live release: the macOS updater bundles on
`preview` were last written 2026-07-04 (x64) and 2026-07-05 (aarch64), and
latest.json 2026-07-13 — three weeks stale as of today. Preview-channel macOS
users had no working update path. The failure also lands AFTER the dmg
upload, so each run looked partly successful while going red.

Deletes this arch's updater bundle before the upload. Matches the STORED
asset name by querying the release rather than guessing the spelling — GitHub
rewrites spaces to dots, so "OmniVoice Studio_x64.app.tar.gz" is stored as
"OmniVoice.Studio_x64.app.tar.gz" and a literal delete-asset by the uploaded
name would silently no-op.

Scoped to the preview path (a v* tag creates a fresh release with nothing to
collide with) and to the job's own arch, so the parallel aarch64/x64 legs
can't touch each other's assets. Verified the filter against all 209 live
preview assets: it matches exactly the 4 colliding updater files and no
versioned artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(release): fail loud when the preview asset sweep can't do its job

The cleanup step treated every `gh` failure as "nothing to clear" — 401, 403,
429 and network errors included. That reintroduces the outage it was written
to fix, with the evidence removed: the stale bundle survives, the Tauri upload
dies with `already_exists`, and the one step that could have explained why is
green. Three weeks of broken macOS Preview updates started exactly this way.

Only an absent release/asset is benign now. A 404 on view means "no preview
release yet" (GH_TOKEN is scoped to this repo, so 404 really is absence); a
404 on delete means someone already removed it, which satisfies the goal. Every
other failure fails the step with the reason printed. An unexpected arch is
also fatal rather than a silent skip — same class of blind spot.

Adds tests/test_release_preview_asset_cleanup.py, which extracts this step's
real shell body from release.yml (so it cannot drift) and runs it against a
stubbed `gh`: 6 of the 8 cases fail against the previous version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-29 01:16:47 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 574b2832c5
commit 602ea6f53e
2 changed files with 226 additions and 0 deletions
+75
View File
@@ -515,6 +515,81 @@ jobs:
mv "$tmp" "$CONF"
echo "Stamped preview version: $PREVIEW_VERSION"
# The rolling `preview` release is REUSED every night, and macOS updater
# artifacts are the only ones Tauri names WITHOUT the version:
#
# OmniVoice Studio_0.4.1-103_x64.dmg <- unique per run, uploads fine
# OmniVoice Studio_x64.app.tar.gz <- constant, collides
#
# So every preview build after the first failed the macOS legs with
# `Validation Failed: {"resource":"ReleaseAsset","code":"already_exists"}`
# — and it failed AFTER the dmg upload, so the run went red while looking
# partially successful. The macOS updater bundles on `preview` went stale
# on 2026-07-04/05 and stayed that way for three weeks: Preview-channel
# macOS users had no working update path, and the nightly run was red
# every night.
#
# Delete this arch's updater bundle before uploading the new one. Scoped
# to the preview path (a `v*` tag makes a fresh release, nothing to
# collide with) and to this job's own arch, so the parallel aarch64/x64
# legs never touch each other's assets.
#
# ONLY an absent release/asset is benign. Auth, permission, rate-limit and
# network failures must not be swallowed: the step would report success
# while the stale asset survived, the upload would then die with
# `already_exists`, and we would be back to the exact outage this step
# exists to prevent — minus the red step that explains why. Since GH_TOKEN
# is scoped to this same repo, a 404 really does mean "not there".
- name: Clear this arch's stale preview updater bundle (macOS)
if: needs.preview-gate.outputs.is_preview == 'true' && runner.os == 'macOS'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -uo pipefail
# aarch64-apple-darwin -> aarch64 ; x86_64-apple-darwin -> x64
case "${{ matrix.arch }}" in
aarch64-*) SUFFIX=aarch64 ;;
x86_64-*) SUFFIX=x64 ;;
*) echo "::error::unexpected arch ${{ matrix.arch }}"; exit 1 ;;
esac
# Match the STORED name, not the uploaded one: GitHub rewrites spaces
# to dots, so "OmniVoice Studio_x64.app.tar.gz" is stored as
# "OmniVoice.Studio_x64.app.tar.gz". Query the release and filter,
# rather than guessing which spelling to pass.
if ! gh release view preview --json assets -q '.assets[].name' \
> /tmp/preview-assets.txt 2> /tmp/gh-view-err.txt; then
if grep -qiE 'not found|HTTP 404' /tmp/gh-view-err.txt; then
echo "No preview release yet — nothing to clear."
exit 0
fi
echo "::error::Could not read the preview release, so a stale ${SUFFIX} bundle may still be there."
echo "Refusing to continue blind — the Tauri upload would fail with already_exists."
cat /tmp/gh-view-err.txt
exit 1
fi
grep -E "[ .]Studio_${SUFFIX}\.app\.tar\.gz(\.sig)?$" /tmp/preview-assets.txt \
> /tmp/stale.txt || true
if [ ! -s /tmp/stale.txt ]; then
echo "No stale ${SUFFIX} updater bundle on preview — nothing to clear."
exit 0
fi
while IFS= read -r name; do
echo "Removing stale preview asset: $name"
if ! gh release delete-asset preview "$name" --yes \
2> /tmp/gh-del-err.txt; then
# Already gone is fine — a re-run or the sibling leg beat us to
# it, and the goal (no asset under this name) is met either way.
if grep -qiE 'not found|HTTP 404' /tmp/gh-del-err.txt; then
echo " (already gone — nothing to collide with)"
continue
fi
echo "::error::Failed to delete stale preview asset $name."
cat /tmp/gh-del-err.txt
exit 1
fi
done < /tmp/stale.txt
- name: Build + release (Tauri)
uses: tauri-apps/tauri-action@v0
env:
+151
View File
@@ -0,0 +1,151 @@
"""The macOS preview updater cleanup step must fail loud, not fail blind.
#1281 added a step that deletes this arch's stale `*.app.tar.gz` from the
rolling `preview` release before Tauri uploads the new one. The first cut
treated *every* `gh` failure as "nothing to clear" — including 401/403/429 and
network errors. That reintroduces the original outage with the evidence
removed: the stale asset survives, the upload dies with `already_exists`, and
the one step that could have explained why is green.
Only an ABSENT release/asset is benign. These tests run the step's real shell
body (extracted from release.yml, so it cannot drift) against a stubbed `gh`.
"""
import os
import shutil
import stat
import subprocess
import sys
import pytest
import yaml
pytestmark = pytest.mark.skipif(
sys.platform == "win32",
reason="step is macOS-only and the script uses POSIX /tmp paths",
)
_WORKFLOW = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
".github", "workflows", "release.yml",
)
_STEP = "Clear this arch's stale preview updater bundle (macOS)"
# One aarch64 bundle + its detached signature, plus assets that must survive:
# the x64 leg's bundle (the sibling job owns it) and the DMGs.
_ASSETS = """\
OmniVoice.Studio_aarch64.app.tar.gz
OmniVoice.Studio_aarch64.app.tar.gz.sig
OmniVoice.Studio_x64.app.tar.gz
OmniVoice_0.4.2_aarch64.dmg
latest.json
"""
def _step_script():
with open(_WORKFLOW, encoding="utf-8") as fh:
wf = yaml.safe_load(fh)
for job in wf["jobs"].values():
for step in job.get("steps", []):
if step.get("name") == _STEP:
# The runner substitutes matrix values before bash sees them.
return step["run"].replace("${{ matrix.arch }}", "aarch64-apple-darwin")
raise AssertionError(f"step {_STEP!r} not found in release.yml")
def _run(tmp_path, view_rc, view_err, delete_rc=0, delete_err="", assets=_ASSETS):
"""Run the step with a `gh` stub on PATH; return (CompletedProcess, deleted)."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
deleted = tmp_path / "deleted.txt"
# Via a file, not an inlined literal: bash single-quotes take \n literally,
# so an embedded repr() would hand the step one long unsplittable line.
assets_file = tmp_path / "assets.txt"
assets_file.write_text(assets)
gh = bin_dir / "gh"
gh.write_text(
"#!/usr/bin/env bash\n"
'if [ "$2" = "view" ]; then\n'
f" cat {str(assets_file)!r}\n"
f' printf %s {view_err!r} >&2\n'
f" exit {view_rc}\n"
"fi\n"
'if [ "$2" = "delete-asset" ]; then\n'
f' echo "$4" >> {str(deleted)!r}\n'
f' printf %s {delete_err!r} >&2\n'
f" exit {delete_rc}\n"
"fi\n"
"exit 0\n"
)
gh.chmod(gh.stat().st_mode | stat.S_IEXEC)
script = tmp_path / "step.sh"
script.write_text(_step_script())
env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", GH_TOKEN="x")
proc = subprocess.run(
[shutil.which("bash") or "/bin/bash", str(script)],
capture_output=True, text=True, env=env, timeout=60,
)
names = deleted.read_text().split() if deleted.exists() else []
return proc, names
def test_missing_preview_release_is_benign(tmp_path):
"""First preview build: there is no release yet, so there is nothing to
collide with. This is the one failure that must NOT fail the job."""
proc, deleted = _run(tmp_path, view_rc=1, view_err="release not found", assets="")
assert proc.returncode == 0, proc.stderr
assert "nothing to clear" in proc.stdout
assert deleted == []
@pytest.mark.parametrize(
"stderr",
[
"HTTP 401: Bad credentials",
"HTTP 403: Resource not accessible by integration",
"HTTP 429: API rate limit exceeded",
"dial tcp: lookup api.github.com: no such host",
],
)
def test_unreadable_release_fails_the_job(tmp_path, stderr):
"""The regression: swallowing these leaves the stale asset in place and the
upload dies later with `already_exists`, with no red step to explain it."""
proc, deleted = _run(tmp_path, view_rc=1, view_err=stderr, assets="")
assert proc.returncode != 0, (
f"a {stderr!r} failure was swallowed — the step cannot know whether a "
f"stale bundle survived, so it must not report success"
)
assert deleted == []
def test_deletes_only_this_arch(tmp_path):
"""The parallel x64 leg owns its own bundle; DMGs and latest.json are not
ours to touch."""
proc, deleted = _run(tmp_path, view_rc=0, view_err="")
assert proc.returncode == 0, proc.stderr
assert deleted == [
"OmniVoice.Studio_aarch64.app.tar.gz",
"OmniVoice.Studio_aarch64.app.tar.gz.sig",
]
def test_already_deleted_asset_is_benign(tmp_path):
"""A re-run (or the sibling leg racing us) already removed it — the goal is
"no asset under this name", which is satisfied."""
proc, _ = _run(tmp_path, view_rc=0, view_err="", delete_rc=1,
delete_err="HTTP 404: Not Found")
assert proc.returncode == 0, proc.stderr
assert "already gone" in proc.stdout
def test_delete_denied_fails_the_job(tmp_path):
"""A permission failure means the stale asset is still there — exactly the
collision the step exists to prevent."""
proc, _ = _run(tmp_path, view_rc=0, view_err="", delete_rc=1,
delete_err="HTTP 403: Resource not accessible by integration")
assert proc.returncode != 0, (
"a failed delete left the stale bundle in place; the upload will fail "
"with already_exists, so this step must fail first and say why"
)