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:
parent
a19e16f718
commit
5185ff3bf2
|
|
@ -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>}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue