Use semver max for latest stable release (#4622)

Select the latest stable desktop release by numeric semver instead of trusting release list order.
This commit is contained in:
Neil 2026-06-03 23:42:33 -07:00 committed by GitHub
parent a19e16f718
commit 5185ff3bf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 182 additions and 7 deletions

View File

@ -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<digit>` (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:-<none>}"

View File

@ -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)
})
}

View File

@ -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)
)
})
})