#!/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 ?? ''} 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) }) }