mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
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:
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user