Merge origin/main (browser copy, TOC, mobile IME, i18n, Windows startup) + add version_suffix input to release-cut

rc kind can now append an extra prerelease identifier (rc.N.perf):
sorts above its base rc.N but below rc.N+1, so perf-branch RCs are
identifiable by clients without outranking the main RC series.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 20:05:49 -04:00
co-authored by Orca
239 changed files with 11988 additions and 2414 deletions
+82 -4
View File
@@ -6,6 +6,51 @@ const API_VERSION = '2022-11-28'
const MAX_RELEASE_BODY_LENGTH = 120_000
const TRUNCATION_NOTICE =
'\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.'
const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/
export function parseDesktopReleaseTag(tag) {
const match = DESKTOP_RELEASE_TAG_PATTERN.exec(tag)
if (!match) {
return null
}
return {
tag,
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
rc: match[4] === undefined ? null : Number(match[4])
}
}
function compareDesktopReleaseTags(a, b) {
const versionDiff = a.major - b.major || a.minor - b.minor || a.patch - b.patch
if (versionDiff !== 0) {
return versionDiff
}
if (a.rc === b.rc) {
return 0
}
if (a.rc === null) {
return 1
}
if (b.rc === null) {
return -1
}
return a.rc - b.rc
}
export function latestPreviousDesktopReleaseTag(tags, tag) {
const current = parseDesktopReleaseTag(tag)
if (!current) {
return ''
}
const previousTags = tags
.map((candidate) => parseDesktopReleaseTag(candidate))
.filter((candidate) => candidate && candidate.tag !== current.tag)
.filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0)
.sort(compareDesktopReleaseTags)
return previousTags.at(-1)?.tag ?? ''
}
function githubHeaders(token) {
return {
@@ -30,6 +75,29 @@ async function githubJson(fetchImpl, url, token, options = {}) {
return res.json()
}
async function fetchRepoTags(repo, token, fetchImpl) {
const tags = []
for (let page = 1; ; page += 1) {
const pageTags = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/tags?per_page=100&page=${page}`,
token
)
if (!Array.isArray(pageTags)) {
throw new Error(`GitHub tags response page ${page} for ${repo} was not an array`)
}
for (const tag of pageTags) {
if (typeof tag?.name === 'string') {
tags.push(tag.name)
}
}
if (pageTags.length < 100) {
break
}
}
return tags
}
export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
if (body.length <= maxLength) {
return body
@@ -60,16 +128,26 @@ export async function createDraftRelease({
throw new Error('token is required')
}
const previousTag = latestPreviousDesktopReleaseTag(
await fetchRepoTags(repo, token, fetchImpl),
tag
)
const generateNotesBody = {
tag_name: tag,
target_commitish: tag,
...(previousTag ? { previous_tag_name: previousTag } : {})
}
// Why: draft releases are invisible to GitHub's generate-notes baseline.
// Passing the previous desktop tag keeps each release from accumulating
// notes from older drafts.
const releaseNotes = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/generate-notes`,
token,
{
method: 'POST',
body: JSON.stringify({
tag_name: tag,
target_commitish: tag
})
body: JSON.stringify(generateNotesBody)
}
)
+118 -5
View File
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { createDraftRelease, truncateReleaseBody } from './create-draft-release.mjs'
import {
createDraftRelease,
latestPreviousDesktopReleaseTag,
parseDesktopReleaseTag,
truncateReleaseBody
} from './create-draft-release.mjs'
function jsonResponse(body, init = {}) {
return {
@@ -24,10 +29,60 @@ describe('truncateReleaseBody', () => {
})
})
describe('parseDesktopReleaseTag', () => {
it('parses stable and rc desktop release tags only', () => {
expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({
tag: 'v1.4.36',
major: 1,
minor: 4,
patch: 36,
rc: null
})
expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({
tag: 'v1.4.36-rc.2',
major: 1,
minor: 4,
patch: 36,
rc: 2
})
expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull()
})
})
describe('latestPreviousDesktopReleaseTag', () => {
it('bounds stable notes to the previous rc when one exists', () => {
expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'v1.4.36'], 'v1.4.36')).toBe(
'v1.4.36-rc.0'
)
})
it('bounds the first rc notes to the previous stable release', () => {
expect(
latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'mobile-v0.0.12'], 'v1.4.36-rc.0')
).toBe('v1.4.35')
})
it('bounds later rc notes to the prior rc', () => {
expect(latestPreviousDesktopReleaseTag(['v1.4.36-rc.0', 'v1.4.36-rc.1'], 'v1.4.36-rc.1')).toBe(
'v1.4.36-rc.0'
)
})
it('returns empty string for the first desktop release when no earlier tag exists', () => {
expect(latestPreviousDesktopReleaseTag(['v1.4.36', 'mobile-v0.0.12'], 'v1.4.36')).toBe('')
expect(latestPreviousDesktopReleaseTag([], 'v1.4.36')).toBe('')
})
it('returns empty string when the current tag is not a desktop release tag', () => {
expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36'], 'mobile-v0.0.12')).toBe('')
})
})
describe('createDraftRelease', () => {
it('creates a draft release with bounded generated notes', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }, { name: 'v1.4.36' }]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
@@ -41,17 +96,23 @@ describe('createDraftRelease', () => {
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1',
expect.any(Object)
)
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
'https://api.github.com/repos/stablyai/orca/releases/generate-notes',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
tag_name: 'v1.4.36',
target_commitish: 'v1.4.36'
target_commitish: 'v1.4.36',
previous_tag_name: 'v1.4.35'
})
})
)
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
3,
'https://api.github.com/repos/stablyai/orca/releases',
expect.objectContaining({
method: 'POST',
@@ -59,7 +120,7 @@ describe('createDraftRelease', () => {
})
)
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(createBody).toMatchObject({
tag_name: 'v1.4.36',
name: 'v1.4.36',
@@ -73,6 +134,7 @@ describe('createDraftRelease', () => {
it('marks rc tags as prereleases', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'v1.4.36-rc.1' }]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
@@ -84,7 +146,58 @@ describe('createDraftRelease', () => {
log: vi.fn()
})
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(createBody.prerelease).toBe(true)
})
it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'mobile-v0.0.12' }]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' })
expect(generateNotesBody).not.toHaveProperty('previous_tag_name')
})
it('paginates through every tag page before choosing the previous tag', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => ({ name: `mobile-v0.0.${index}` }))
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(firstPage))
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1',
expect.any(Object)
)
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=2',
expect.any(Object)
)
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(generateNotesBody.previous_tag_name).toBe('v1.4.35')
})
})