From 5185ff3bf2715677a423ca74dacd83f7e7b83f0d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:42:33 -0700 Subject: [PATCH] Use semver max for latest stable release (#4622) Select the latest stable desktop release by numeric semver instead of trusting release list order. --- .github/workflows/release-cut.yml | 14 +-- config/scripts/latest-stable-release.mjs | 87 ++++++++++++++++++ config/scripts/latest-stable-release.test.mjs | 88 +++++++++++++++++++ 3 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 config/scripts/latest-stable-release.mjs create mode 100644 config/scripts/latest-stable-release.test.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 1bb0240c0..909d24bd1 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -196,7 +196,7 @@ jobs: run: | set -euo pipefail - # Latest stable release tag, picked by *tag shape*: + # Latest stable release tag, picked by *tag shape* and semver max: # - must start with `v` (desktop convention, e.g. v1.3.32) # - must NOT contain `-rc.` (not a prerelease) # @@ -212,12 +212,12 @@ jobs: # `mobile`, `Number("mobile")` → NaN → 0, and a patch-bump would # produce `0.0.1` — exactly the wedge on 2026-05-04 (run # 25304336767). Any non-desktop tag shape must be excluded here. - latest_stable="$(gh release list \ - --repo "$GITHUB_REPOSITORY" \ - --exclude-drafts \ - --limit 50 \ - --json tagName \ - --jq '[.[] | .tagName | select(test("^v[0-9]") and (test("-rc\\.") | not))] | .[0] // ""')" + # + # Why not `gh release list` order: GitHub can list a newer published + # stable after older releases. On 2026-06-04, v1.4.44 existed but + # the list returned v1.4.42 first, causing a manual RC cut to reopen + # the already-shipped 1.4.43 series as v1.4.43-rc.0. + latest_stable="$(node config/scripts/latest-stable-release.mjs)" latest_stable="${latest_stable#v}" echo "Latest stable: ${latest_stable:-}" diff --git a/config/scripts/latest-stable-release.mjs b/config/scripts/latest-stable-release.mjs new file mode 100644 index 000000000..c00646673 --- /dev/null +++ b/config/scripts/latest-stable-release.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import { pathToFileURL } from 'node:url' + +const API_VERSION = '2022-11-28' +const DESKTOP_STABLE_TAG_PATTERN = /^v([0-9]+)\.([0-9]+)\.([0-9]+)$/ + +export function parseDesktopStableTag(tag) { + const match = DESKTOP_STABLE_TAG_PATTERN.exec(tag) + if (!match) { + return null + } + + return { + tag, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]) + } +} + +export function latestStableDesktopReleaseTag(releases) { + const stableTags = releases + .filter((release) => release?.draft !== true) + .map((release) => parseDesktopStableTag(release?.tag_name ?? release?.tagName ?? '')) + .filter(Boolean) + .sort((a, b) => a.major - b.major || a.minor - b.minor || a.patch - b.patch) + + return stableTags.at(-1)?.tag ?? '' +} + +async function githubJson(fetchImpl, url, token) { + const res = await fetchImpl(url, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': API_VERSION + } + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`GitHub request failed ${res.status} ${res.statusText}: ${body.slice(0, 300)}`) + } + return res.json() +} + +export async function fetchReleases(repo, token, fetchImpl = fetch) { + if (!repo) { + throw new Error('repo is required') + } + if (!token) { + throw new Error('token is required') + } + + const releases = [] + for (let page = 1; ; page += 1) { + const pageReleases = await githubJson( + fetchImpl, + `https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`, + token + ) + if (!Array.isArray(pageReleases)) { + throw new Error(`GitHub releases response page ${page} for ${repo} was not an array`) + } + + releases.push(...pageReleases) + if (pageReleases.length < 100) { + break + } + } + + return releases +} + +async function main() { + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca' + const releases = await fetchReleases(repo, token) + process.stdout.write(latestStableDesktopReleaseTag(releases)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) +} diff --git a/config/scripts/latest-stable-release.test.mjs b/config/scripts/latest-stable-release.test.mjs new file mode 100644 index 000000000..ab56e73a2 --- /dev/null +++ b/config/scripts/latest-stable-release.test.mjs @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { + fetchReleases, + latestStableDesktopReleaseTag, + parseDesktopStableTag +} from './latest-stable-release.mjs' + +function jsonResponse(body, init = {}) { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + json: vi.fn(async () => body), + text: vi.fn(async () => (typeof body === 'string' ? body : JSON.stringify(body))) + } +} + +describe('parseDesktopStableTag', () => { + it('accepts only desktop stable tags', () => { + expect(parseDesktopStableTag('v1.4.44')).toMatchObject({ + tag: 'v1.4.44', + major: 1, + minor: 4, + patch: 44 + }) + expect(parseDesktopStableTag('v1.4.44-rc.0')).toBeNull() + expect(parseDesktopStableTag('mobile-v0.0.11')).toBeNull() + expect(parseDesktopStableTag('cli-v4.12.28')).toBeNull() + }) +}) + +describe('latestStableDesktopReleaseTag', () => { + it('chooses the highest stable semver instead of release list order', () => { + const releases = [ + { tag_name: 'v1.4.43-rc.0', draft: false }, + { tag_name: 'v1.4.42', draft: false }, + { tag_name: 'v1.4.44', draft: false }, + { tag_name: 'mobile-v0.0.11', draft: false } + ] + + expect(latestStableDesktopReleaseTag(releases)).toBe('v1.4.44') + }) + + it('ignores draft stable releases', () => { + const releases = [ + { tag_name: 'v1.4.45', draft: true }, + { tag_name: 'v1.4.44', draft: false } + ] + + expect(latestStableDesktopReleaseTag(releases)).toBe('v1.4.44') + }) + + it('returns empty when no published stable desktop release exists', () => { + expect( + latestStableDesktopReleaseTag([ + { tag_name: 'v1.4.44-rc.0', draft: false }, + { tag_name: 'mobile-v0.0.11', draft: false } + ]) + ).toBe('') + }) +}) + +describe('fetchReleases', () => { + it('fetches all release pages', async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + tag_name: `v1.0.${index}`, + draft: false + })) + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(jsonResponse(firstPage)) + .mockResolvedValueOnce(jsonResponse([{ tag_name: 'v1.4.44', draft: false }])) + + const releases = await fetchReleases('stablyai/orca', 'token', fetchImpl) + + expect(releases).toHaveLength(101) + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1', + expect.any(Object) + ) + expect(fetchImpl).toHaveBeenNthCalledWith( + 2, + 'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=2', + expect.any(Object) + ) + }) +})