fix(release): keep Preview ahead of Stable (#1763)

Closes #1762.
This commit is contained in:
Palash Debnath
2026-09-02 07:49:22 +05:30
committed by GitHub
parent ccd984f324
commit e2446c3e61
5 changed files with 166 additions and 25 deletions
+23 -24
View File
@@ -148,15 +148,20 @@ jobs:
preview-gate:
name: Preview gate
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
is_preview: ${{ steps.decide.outputs.is_preview }}
proceed: ${{ steps.decide.outputs.proceed }}
stable_tag: ${{ steps.decide.outputs.stable_tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
- id: decide
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
event="${{ github.event_name }}"
@@ -171,6 +176,13 @@ jobs:
exit 1
fi
echo "is_preview=true" >> "$GITHUB_OUTPUT"
# Resolve once before the matrix starts so every platform stamps
# against the same immutable Stable-channel snapshot.
STABLE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)
[[ "$STABLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "::error::latest stable release has an invalid tag"; exit 1;
}
echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"
else
echo "is_preview=false" >> "$GITHUB_OUTPUT"
fi
@@ -497,35 +509,22 @@ jobs:
echo "APPLE_TEAM_ID=$TID"
} >> "$GITHUB_ENV"
# Stamp each preview build with a unique, monotonically increasing semver
# PRERELEASE so the updater actually offers it (a rolling preview that
# always reported the static 0.3.0 never looked "newer", so no update was
# ever delivered). Ephemeral, CI-only — never committed. Tauri reads the
# bundle + updater version from tauri.conf.json, so rewriting it here
# stamps the artifacts + latest.json. Under the versioning hard rule
# (owner-set 2026-06-11) main is always last-release + 1, so BASE-N is a
# prerelease of the NEXT version and semver-sorts ABOVE the last stable
# (0.3.6-N > 0.3.5) — preview users naturally upgrade past stable, and
# the Windows MSI ProductVersion (which strips the prerelease → 0.3.6)
# is also correctly above the last stable.
# Stamp each preview with a numeric prerelease that is strictly above the
# latest stable release. Main may intentionally retain the released
# version while AUTO_VERSION_BUMP is disabled; in that case the helper
# advances the preview base by one patch so stable users can still opt in
# and receive it. The edit is ephemeral and never committed.
- name: Stamp preview version
if: needs.preview-gate.outputs.is_preview == 'true'
shell: bash
env:
STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }}
run: |
set -euo pipefail
# package.json is the single source of truth; tauri.conf.json reads its
# version from it ("version": "../package.json"), so stamping
# package.json restamps the whole bundle.
CONF=frontend/package.json
BASE=$(jq -r .version "$CONF")
# MSI/WiX requires the semver pre-release identifier to be numeric-only
# (and <= 65535). "preview.N" hard-fails the Windows bundler, so the
# preview stamp is BASE-N — still sorts below the stable BASE for the
# updater, still unique per run.
PREVIEW_VERSION="${BASE}-${{ github.run_number }}"
tmp=$(mktemp)
jq --arg v "$PREVIEW_VERSION" '.version = $v' "$CONF" > "$tmp"
mv "$tmp" "$CONF"
PREVIEW_VERSION=$(python scripts/stamp-preview-version.py \
--package-json frontend/package.json \
--stable-tag "$STABLE_TAG" \
--run-number "${{ github.run_number }}")
echo "Stamped preview version: $PREVIEW_VERSION"
# The rolling `preview` release is REUSED every night, and macOS updater
+1
View File
@@ -11,6 +11,7 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- Preview builds now stay newer than Stable even when automatic post-release version bumps are disabled (#1762)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available.
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
+1 -1
View File
@@ -96,7 +96,7 @@ bug to fix immediately, not backlog.
| Channel | Source | Produced by | How to verify |
|---|---|---|---|
| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi/exe, AppImage/deb, `latest.json`; body = the CHANGELOG section (not the auto-generated fallback), followed by per-platform checksums and a **Contributors** avatar strip (owner + every PR author for the tag — the `contributors-strip` job) |
| **Preview** updater channel (rolling `preview` prerelease) | **`main` only** | `release.yml` nightly cron / manual dispatch | preview `latest.json` stamps `X.Y.Z-N` and semver-sorts above stable |
| **Preview** updater channel (rolling `preview` prerelease) | **`main` only** | `release.yml` nightly cron / manual dispatch | preview `latest.json` uses main's version when it is ahead; otherwise it advances the stable patch, then appends `-N` so it semver-sorts above stable |
| GHCR CUDA image: `:X.Y.Z`, `:X.Y`, `:stable` | the tag | `docker.yml` on tag push | `docker manifest inspect ghcr.io/debpalash/omnivoice-studio:X.Y.Z` |
| GHCR ROCm image: `:X.Y.Z-rocm`, `:X.Y-rocm`, `:stable-rocm` | the tag | `docker.yml` on tag push | same, with `-rocm` suffix |
| Docker Hub mirror of **all** the above tags | the tag | `docker.yml` (gated on `DOCKERHUB_*` secrets) | tag list at hub.docker.com/r/palashdeb/omnivoice-studio/tags |
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Stamp a Preview build above the latest stable VoiceStudio release."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
_VERSION_RE = re.compile(r"^(?:v)?(\d+)\.(\d+)\.(\d+)$")
def _release_tuple(value: str) -> tuple[int, int, int]:
match = _VERSION_RE.fullmatch(value.strip())
if match is None:
raise ValueError(f"expected a release version X.Y.Z, got {value!r}")
return tuple(map(int, match.groups()))
def preview_version(package_version: str, stable_tag: str, run_number: int) -> str:
"""Return a numeric-prerelease SemVer strictly above ``stable_tag``."""
if not 0 < run_number <= 65_535:
raise ValueError("run number must be between 1 and 65535 for WiX")
package = _release_tuple(package_version)
stable = _release_tuple(stable_tag)
base = package if package > stable else (stable[0], stable[1], stable[2] + 1)
return f"{base[0]}.{base[1]}.{base[2]}-{run_number}"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--package-json", type=Path, required=True)
parser.add_argument("--stable-tag", required=True)
parser.add_argument("--run-number", type=int, required=True)
args = parser.parse_args()
package = json.loads(args.package_json.read_text(encoding="utf-8"))
stamped = preview_version(package["version"], args.stable_tag, args.run_number)
package["version"] = stamped
args.package_json.write_text(
json.dumps(package, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(stamped)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,90 @@
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "stamp-preview-version.py"
SPEC = importlib.util.spec_from_file_location("stamp_preview_version", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
@pytest.mark.parametrize(
("package", "stable", "expected"),
[
("0.5.2", "v0.5.1", "0.5.2-144"),
("0.5.2", "v0.5.2", "0.5.3-144"),
("0.4.9", "v0.5.2", "0.5.3-144"),
],
)
def test_preview_is_based_above_stable(package: str, stable: str, expected: str) -> None:
assert MODULE.preview_version(package, stable, 144) == expected
@pytest.mark.parametrize("run_number", [0, 65_536])
def test_preview_rejects_wix_incompatible_run_number(run_number: int) -> None:
with pytest.raises(ValueError, match="WiX"):
MODULE.preview_version("0.5.2", "v0.5.1", run_number)
def test_cli_rewrites_only_the_package_version(tmp_path: Path) -> None:
package_json = tmp_path / "package.json"
package_json.write_text(
json.dumps({"name": "omnivoice-studio", "version": "0.5.2"}) + "\n",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--package-json",
str(package_json),
"--stable-tag",
"v0.5.2",
"--run-number",
"7",
],
check=True,
capture_output=True,
text=True,
)
assert result.stdout.strip() == "0.5.3-7"
assert json.loads(package_json.read_text(encoding="utf-8")) == {
"name": "omnivoice-studio",
"version": "0.5.3-7",
}
def test_release_workflow_resolves_stable_once_before_the_matrix() -> None:
workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text(
encoding="utf-8"
)
preview_gate = workflow.index(" preview-gate:")
build_matrix = workflow.index(" build:", preview_gate)
stable_lookup = workflow.index(
"gh release view --repo \"$GITHUB_REPOSITORY\" --json tagName"
)
lookup_command = "gh release view --repo \"$GITHUB_REPOSITORY\" --json tagName"
preview_gate_body = workflow[preview_gate:build_matrix]
build_body = workflow[build_matrix:]
assert preview_gate < stable_lookup < build_matrix
assert workflow.count(lookup_command) == 1
assert "STABLE_TAG=$(gh release view" in preview_gate_body
assert '[[ "$STABLE_TAG" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]' in preview_gate_body
assert 'echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"' in preview_gate_body
assert lookup_command not in build_body
assert "stable_tag: ${{ steps.decide.outputs.stable_tag }}" in workflow
assert "STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }}" in build_body
assert "python scripts/stamp-preview-version.py" in workflow
assert '--stable-tag "$STABLE_TAG"' in workflow