diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 895c65f6..5aa06590 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -144,6 +144,12 @@ jobs: if: github.event_name == 'release' uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: + # Carry the release's own flag. The action defaults `prerelease` to + # false and writes it back, so attaching assets silently promoted a + # pre-release to the latest release -- which also fires `released`, + # pushing :latest to GHCR, and makes the in-app updater offer a build + # that was never verified. + prerelease: ${{ github.event.release.prerelease }} files: | dist/StemDeck-Linux-x64.tar.gz dist/StemDeck-Linux-x64.tar.gz.sha256 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 3c137606..782c28d5 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -119,6 +119,12 @@ jobs: - name: upload artifacts uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: + # Carry the release's own flag. The action defaults `prerelease` to + # false and writes it back, so attaching assets silently promoted a + # pre-release to the latest release -- which also fires `released`, + # pushing :latest to GHCR, and makes the in-app updater offer a build + # that was never verified. + prerelease: ${{ github.event.release.prerelease }} files: | .build/macos-dist/StemDeck-macOS-arm64.dmg .build/StemDeck-runtime-macOS-arm64.tar.zst diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index a5c9ed48..70883061 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -95,6 +95,12 @@ jobs: - name: upload artifacts uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: + # Carry the release's own flag. The action defaults `prerelease` to + # false and writes it back, so attaching assets silently promoted a + # pre-release to the latest release -- which also fires `released`, + # pushing :latest to GHCR, and makes the in-app updater offer a build + # that was never verified. + prerelease: ${{ github.event.release.prerelease }} files: | dist/StemDeck-Windows-x64.NVIDIA.zip dist/StemDeck-Windows-x64.NVIDIA.zip.sha256 diff --git a/static/js/catalog.js b/static/js/catalog.js index 189fd1db..a9b6ee20 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -2513,8 +2513,18 @@ async function checkForUpdate() { // Newest first, as GitHub returns them. Drafts are invisible to an // unauthenticated request anyway, but filter them so a maintainer running a // dev build is not offered a release that has no assets yet. + // + // Pre-releases are skipped as well: a release is published as a pre-release + // first, verified, and only then promoted ("Set as the latest release"), so + // nobody is offered a build that has not been through that. The list + // endpoint is used rather than /releases/latest because it keeps the choice + // here, in code, rather than in GitHub's endpoint semantics. It assumes a + // stable release inside the last 10 -- true unless ten consecutive + // pre-releases go out without one being promoted. const releases = await res.json(); - const data = Array.isArray(releases) ? releases.find((r) => !r.draft) : null; + const data = Array.isArray(releases) + ? releases.find((r) => !r.draft && !r.prerelease) + : null; if (!data) return; const latest = normalizeVersion(data.tag_name); // Compare canonically so a PEP440 current version (0.7.0a9) matches the diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs index 4a64972d..421d1391 100644 --- a/tests/e2e/helpers.mjs +++ b/tests/e2e/helpers.mjs @@ -169,9 +169,12 @@ export async function stubUpdateCheck(page, { available = false } = {}) { route.fulfill({ status: 200, contentType: "application/json", - // An ARRAY: the app polls /releases (the list), not /releases/latest, - // so that a version published as a pre-release is still seen. + // An ARRAY: the app polls /releases (the list), not /releases/latest. + // The unpromoted pre-release in front of the stable one is deliberate: + // it must be skipped, because a release is only offered once it has + // been promoted to the latest release. body: JSON.stringify([ + { tag_name: "v9.9.10", draft: false, prerelease: true, body: "unpromoted", html_url: "https://example.invalid", assets: [] }, { tag_name: "v9.9.9", draft: false, prerelease: false, body: "notes", html_url: "https://example.invalid", assets: [] }, ]), })); diff --git a/tests/e2e/report-failure.spec.mjs b/tests/e2e/report-failure.spec.mjs index 6a58ce45..6b3af98f 100644 --- a/tests/e2e/report-failure.spec.mjs +++ b/tests/e2e/report-failure.spec.mjs @@ -97,6 +97,11 @@ test.describe("failure notifications", () => { await openStudio(page, { tauri: true, updateAvailable: true }); await expect(page.locator("#notifReleaseCard")).not.toHaveClass(/hidden/); await expect(page.locator("#notifBadge")).not.toHaveClass(/hidden/); + // The newest release in the stub is an unpromoted pre-release. The card must + // name the promoted one behind it: a release is offered only once it has + // been promoted to the latest release, so nobody is updated to a build that + // was never verified. + await expect(page.locator("#notifReleaseDesc")).toHaveText("v9.9.9"); await failAnExport(page); await openBell(page);