mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
electron-builder --publish always was creating a public GitHub release as soon as the first platform uploaded, so /releases/latest could serve a missing Windows exe. Keep the main-repo publisher on draft, pin draft creation to the tag commit, re-draft immediately if anything flips public, and refuse mac publish after the parent cut is cancelled.
412 lines
13 KiB
JavaScript
412 lines
13 KiB
JavaScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
createDraftRelease,
|
|
latestPreviousPublishedDesktopReleaseTag,
|
|
parseDesktopReleaseTag,
|
|
truncateReleaseBody
|
|
} from './create-draft-release.mjs'
|
|
|
|
function release(tag, options = {}) {
|
|
return {
|
|
draft: false,
|
|
tag_name: tag,
|
|
...options
|
|
}
|
|
}
|
|
|
|
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('truncateReleaseBody', () => {
|
|
it('leaves short release notes unchanged', () => {
|
|
expect(truncateReleaseBody('short notes', 120_000)).toBe('short notes')
|
|
})
|
|
|
|
it('caps long release notes and appends an explanation', () => {
|
|
const body = truncateReleaseBody('a'.repeat(130_000), 1_000)
|
|
|
|
expect(body).toHaveLength(1_000)
|
|
expect(body).toContain('Release notes were truncated')
|
|
})
|
|
})
|
|
|
|
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('latestPreviousPublishedDesktopReleaseTag', () => {
|
|
it('bounds stable notes to the previous stable release when rcs exist', () => {
|
|
expect(
|
|
latestPreviousPublishedDesktopReleaseTag(
|
|
[release('v1.4.35'), release('v1.4.36-rc.0'), release('v1.4.36')],
|
|
'v1.4.36'
|
|
)
|
|
).toBe('v1.4.35')
|
|
})
|
|
|
|
it('does not collapse a stable changelog to its rc-to-stable version bump', () => {
|
|
expect(
|
|
latestPreviousPublishedDesktopReleaseTag(
|
|
[
|
|
release('v1.4.120'),
|
|
release('v1.4.121-rc.0'),
|
|
release('v1.4.121-rc.6'),
|
|
release('v1.4.121')
|
|
],
|
|
'v1.4.121'
|
|
)
|
|
).toBe('v1.4.120')
|
|
})
|
|
|
|
it('bounds the first rc notes to the previous stable release', () => {
|
|
expect(
|
|
latestPreviousPublishedDesktopReleaseTag(
|
|
[release('v1.4.35'), release('v1.4.36-rc.0'), release('mobile-v0.0.12')],
|
|
'v1.4.36-rc.0'
|
|
)
|
|
).toBe('v1.4.35')
|
|
})
|
|
|
|
it('bounds later rc notes to the prior rc', () => {
|
|
expect(
|
|
latestPreviousPublishedDesktopReleaseTag(
|
|
[release('v1.4.36-rc.0'), release('v1.4.36-rc.1')],
|
|
'v1.4.36-rc.1'
|
|
)
|
|
).toBe('v1.4.36-rc.0')
|
|
})
|
|
|
|
it('ignores draft releases as public changelog boundaries', () => {
|
|
expect(
|
|
latestPreviousPublishedDesktopReleaseTag(
|
|
[release('v1.4.35'), release('v1.4.36-rc.0', { draft: true }), release('v1.4.36-rc.1')],
|
|
'v1.4.36-rc.1'
|
|
)
|
|
).toBe('v1.4.35')
|
|
})
|
|
|
|
it('returns empty string for the first desktop release when no earlier tag exists', () => {
|
|
expect(
|
|
latestPreviousPublishedDesktopReleaseTag(
|
|
[release('v1.4.36'), release('mobile-v0.0.12')],
|
|
'v1.4.36'
|
|
)
|
|
).toBe('')
|
|
expect(latestPreviousPublishedDesktopReleaseTag([], 'v1.4.36')).toBe('')
|
|
})
|
|
|
|
it('returns empty string when the current tag is not a desktop release tag', () => {
|
|
expect(
|
|
latestPreviousPublishedDesktopReleaseTag(
|
|
[release('v1.4.35'), release('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([release('v1.4.35')]))
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
|
|
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
|
|
|
await createDraftRelease({
|
|
repo: 'stablyai/orca',
|
|
tag: 'v1.4.36',
|
|
token: 'token',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log: vi.fn()
|
|
})
|
|
|
|
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/generate-notes',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
tag_name: 'v1.4.36',
|
|
target_commitish: 'v1.4.36',
|
|
previous_tag_name: 'v1.4.35'
|
|
})
|
|
})
|
|
)
|
|
expect(fetchImpl).toHaveBeenNthCalledWith(
|
|
3,
|
|
'https://api.github.com/repos/stablyai/orca/releases',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: expect.any(String)
|
|
})
|
|
)
|
|
|
|
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
|
expect(createBody).toMatchObject({
|
|
tag_name: 'v1.4.36',
|
|
target_commitish: 'abc123',
|
|
name: 'v1.4.36',
|
|
draft: true,
|
|
prerelease: false,
|
|
make_latest: 'false'
|
|
})
|
|
expect(createBody.body).toHaveLength(120_000)
|
|
expect(createBody.body).toContain('Release notes were truncated')
|
|
})
|
|
|
|
it('marks rc tags as prereleases', async () => {
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse([release('v1.4.36')]))
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
|
|
|
|
await createDraftRelease({
|
|
repo: 'stablyai/orca',
|
|
tag: 'v1.4.36-rc.1',
|
|
token: 'token',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log: vi.fn()
|
|
})
|
|
|
|
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
|
expect(createBody.prerelease).toBe(true)
|
|
})
|
|
|
|
it('regenerates notes for an existing draft release', async () => {
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })])
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'stale' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'notes' }))
|
|
|
|
await createDraftRelease({
|
|
repo: 'stablyai/orca',
|
|
tag: 'v1.4.36',
|
|
token: 'token',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log: vi.fn()
|
|
})
|
|
|
|
expect(fetchImpl).toHaveBeenNthCalledWith(
|
|
3,
|
|
'https://api.github.com/repos/stablyai/orca/releases/42',
|
|
expect.not.objectContaining({ method: expect.anything() })
|
|
)
|
|
expect(fetchImpl).toHaveBeenNthCalledWith(
|
|
4,
|
|
'https://api.github.com/repos/stablyai/orca/releases/42',
|
|
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ body: 'notes' }) })
|
|
)
|
|
})
|
|
|
|
it('skips the update when the draft was published while notes were generated', async () => {
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })])
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: false }))
|
|
|
|
await createDraftRelease({
|
|
repo: 'stablyai/orca',
|
|
tag: 'v1.4.36',
|
|
token: 'token',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log: vi.fn()
|
|
})
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(3)
|
|
expect(fetchImpl).toHaveBeenNthCalledWith(
|
|
3,
|
|
'https://api.github.com/repos/stablyai/orca/releases/42',
|
|
expect.not.objectContaining({ method: expect.anything() })
|
|
)
|
|
})
|
|
|
|
it('restores the published body when publication lands between the check and the patch', async () => {
|
|
const log = vi.fn()
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })])
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'hand-written notes' }))
|
|
|
|
await createDraftRelease({
|
|
repo: 'stablyai/orca',
|
|
tag: 'v1.4.36',
|
|
token: 'token',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log
|
|
})
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(6)
|
|
expect(fetchImpl).toHaveBeenNthCalledWith(
|
|
6,
|
|
'https://api.github.com/repos/stablyai/orca/releases/42',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ body: 'hand-written notes' })
|
|
})
|
|
)
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('restored its published body'))
|
|
})
|
|
|
|
it('leaves a body written after the patch in place instead of rolling it back', async () => {
|
|
const log = vi.fn()
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
jsonResponse([release('v1.4.35'), release('v1.4.36', { draft: true, id: 42 })])
|
|
)
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: true, body: 'hand-written notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'notes' }))
|
|
.mockResolvedValueOnce(jsonResponse({ id: 42, draft: false, body: 'newer published body' }))
|
|
|
|
await createDraftRelease({
|
|
repo: 'stablyai/orca',
|
|
tag: 'v1.4.36',
|
|
token: 'token',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log
|
|
})
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(5)
|
|
expect(log).toHaveBeenCalledWith(expect.stringContaining('leaving the newer body in place'))
|
|
})
|
|
|
|
it('preserves notes on an existing published release', async () => {
|
|
const fetchImpl = vi.fn().mockResolvedValueOnce(jsonResponse([release('v1.4.36', { id: 42 })]))
|
|
|
|
await createDraftRelease({
|
|
repo: 'stablyai/orca',
|
|
tag: 'v1.4.36',
|
|
token: 'token',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log: vi.fn()
|
|
})
|
|
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
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([release('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',
|
|
targetCommitish: 'abc123',
|
|
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 release page before choosing the previous release', async () => {
|
|
const firstPage = Array.from({ length: 100 }, (_, index) => release(`mobile-v0.0.${index}`))
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse(firstPage))
|
|
.mockResolvedValueOnce(jsonResponse([release('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',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log: vi.fn()
|
|
})
|
|
|
|
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)
|
|
)
|
|
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
|
expect(generateNotesBody.previous_tag_name).toBe('v1.4.35')
|
|
})
|
|
|
|
it('refuses an untagged GitHub draft so electron-builder cannot publish latest', async () => {
|
|
const fetchImpl = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(jsonResponse([]))
|
|
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({ tag_name: 'untagged-abc', name: 'v1.4.36', draft: true })
|
|
)
|
|
|
|
await expect(
|
|
createDraftRelease({
|
|
repo: 'stablyai/orca',
|
|
tag: 'v1.4.36',
|
|
token: 'token',
|
|
targetCommitish: 'abc123',
|
|
fetchImpl,
|
|
log: vi.fn()
|
|
})
|
|
).rejects.toThrow('GitHub created draft release untagged-abc instead of draft v1.4.36')
|
|
})
|
|
})
|