Files
orca/config/scripts/create-draft-release.mjs
T
Neil 3cadcabe11 fix(release): keep GitHub releases draft until all assets exist (#21835)
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.
2026-09-20 14:01:15 -07:00

283 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { pathToFileURL } from 'node:url'
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 latestPreviousPublishedDesktopReleaseTag(releases, tag) {
const current = parseDesktopReleaseTag(tag)
if (!current) {
return ''
}
const previousReleases = releases
.filter((release) => release?.draft === false && typeof release.tag_name === 'string')
.map((release) => parseDesktopReleaseTag(release.tag_name))
.filter((candidate) => candidate && candidate.tag !== current.tag)
.filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0)
// Why: public changelogs should be bounded by releases users could see;
// stable releases summarize since the prior stable, not the latest RC.
.filter((candidate) => current.rc !== null || candidate.rc === null)
.sort(compareDesktopReleaseTags)
return previousReleases.at(-1)?.tag ?? ''
}
function githubHeaders(token) {
return {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': API_VERSION
}
}
async function githubJson(fetchImpl, url, token, options = {}) {
const res = await fetchImpl(url, {
...options,
headers: {
...githubHeaders(token),
...options.headers
}
})
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()
}
async function fetchRepoReleases(repo, token, fetchImpl) {
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
}
export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
if (body.length <= maxLength) {
return body
}
const availableLength = maxLength - TRUNCATION_NOTICE.length
if (availableLength <= 0) {
throw new Error('Release truncation notice is longer than the maximum release body length')
}
return `${body.slice(0, availableLength).trimEnd()}${TRUNCATION_NOTICE}`
}
export async function createDraftRelease({
repo,
tag,
token,
targetCommitish,
fetchImpl = fetch,
log = console.log
}) {
if (!repo) {
throw new Error('repo is required')
}
if (!tag) {
throw new Error('tag is required')
}
if (!token) {
throw new Error('token is required')
}
if (!targetCommitish) {
throw new Error('targetCommitish is required')
}
const releases = await fetchRepoReleases(repo, token, fetchImpl)
const existingRelease = releases.find((release) => release?.tag_name === tag)
if (existingRelease && existingRelease.draft !== true) {
log(`Release ${tag} already exists and is published.`)
return
}
const previousTag = latestPreviousPublishedDesktopReleaseTag(releases, tag)
const generateNotesBody = {
tag_name: tag,
target_commitish: tag,
...(previousTag ? { previous_tag_name: previousTag } : {})
}
// Why: GitHub's generate-notes baseline ignores draft releases, so pass the
// previous public changelog boundary explicitly.
const releaseNotes = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/generate-notes`,
token,
{
method: 'POST',
body: JSON.stringify(generateNotesBody)
}
)
const generatedBody = typeof releaseNotes.body === 'string' ? releaseNotes.body : ''
const body = truncateReleaseBody(generatedBody)
const name =
typeof releaseNotes.name === 'string' && releaseNotes.name.length > 0 ? releaseNotes.name : tag
const prerelease = tag.includes('-rc.')
if (existingRelease) {
if (!Number.isInteger(existingRelease.id)) {
throw new Error(`Draft release ${tag} is missing a GitHub release id`)
}
// Why: the listing is a snapshot; the draft can be published while notes
// generate, and patching then overwrites a live release body.
const currentRelease = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/${existingRelease.id}`,
token
)
if (currentRelease?.draft !== true) {
log(`Release ${tag} was published while notes were generated; leaving it unchanged.`)
return
}
// Why: the PATCH endpoint supports no conditional/versioned update, so the
// GET above cannot close the window. The PATCH response reports the state we
// actually wrote to; if publication won, put the published body back.
const patchedRelease = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/${existingRelease.id}`,
token,
{
method: 'PATCH',
body: JSON.stringify({ body })
}
)
if (patchedRelease?.draft !== true) {
const publishedBody = typeof currentRelease.body === 'string' ? currentRelease.body : ''
if (publishedBody === body) {
log(`Release ${tag} was published while notes were patched; its body is unchanged.`)
return
}
// Why: the rollback must not clobber a body written after our PATCH, so
// restore only while the release still carries exactly what we wrote.
const releaseBeforeRollback = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/${existingRelease.id}`,
token
)
if (releaseBeforeRollback?.body !== body) {
log(
`Release ${tag} was published and its body changed again while notes were patched; leaving the newer body in place.`
)
return
}
await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/${existingRelease.id}`,
token,
{
method: 'PATCH',
body: JSON.stringify({ body: publishedBody })
}
)
log(
`Release ${tag} was published while notes were patched; restored its published body and left the generated notes unapplied.`
)
return
}
} else {
// Why target_commitish is the tag commit, not omitted: GitHub defaults it
// to the repo default branch. A release-cut tag is a detached bump commit,
// so that default creates an untagged draft. electron-builder then misses
// it by tag name and `--publish always` opens a public release with the
// first platform's assets, which /releases/latest serves without the exe.
const createdRelease = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases`,
token,
{
method: 'POST',
body: JSON.stringify({
tag_name: tag,
target_commitish: targetCommitish,
name,
body,
draft: true,
prerelease,
make_latest: 'false'
})
}
)
if (createdRelease?.draft !== true || createdRelease?.tag_name !== tag) {
throw new Error(
`GitHub created ${createdRelease?.draft ? 'draft' : 'published'} release ${createdRelease?.tag_name ?? '<missing>'} instead of draft ${tag}`
)
}
}
if (generatedBody.length !== body.length) {
log(
`${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with truncated generated notes (${body.length} chars).`
)
} else {
log(
`${existingRelease ? 'Updated' : 'Created'} draft release ${tag} with generated notes (${body.length} chars).`
)
}
}
async function main() {
const tag = process.argv[2]
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
const targetCommitish = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
await createDraftRelease({ repo, tag, token, targetCommitish })
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error.message)
process.exit(1)
})
}