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:
co-authored by
Claude Opus 5
parent
574b2832c5
commit
602ea6f53e
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user