171 lines
4.7 KiB
JavaScript
171 lines
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from 'node:child_process'
|
|
import { appendFileSync } from 'node:fs'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { verifyRequiredReleaseAssets } from './verify-release-required-assets.mjs'
|
|
|
|
const API_VERSION = '2022-11-28'
|
|
const RELEASE_CUT_AUTHOR = 'github-actions[bot]'
|
|
const DESKTOP_RC_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$/
|
|
|
|
export function isReleaseCutDraft(release) {
|
|
return (
|
|
release?.draft === true &&
|
|
release?.author?.login === RELEASE_CUT_AUTHOR &&
|
|
typeof release?.tag_name === 'string' &&
|
|
DESKTOP_RC_TAG_PATTERN.test(release.tag_name)
|
|
)
|
|
}
|
|
|
|
function isRcTag(tag) {
|
|
return tag.includes('-rc.')
|
|
}
|
|
|
|
function gitOutput(args, cwd) {
|
|
return execFileSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore']
|
|
}).trim()
|
|
}
|
|
|
|
export function isTagBuiltFromCurrentRef(tag, { cwd = process.cwd() } = {}) {
|
|
try {
|
|
const tagCommit = gitOutput(['rev-parse', `${tag}^{}`], cwd)
|
|
const currentCommit = gitOutput(['rev-parse', 'HEAD'], cwd)
|
|
if (tagCommit === currentCommit) {
|
|
return true
|
|
}
|
|
|
|
return gitOutput(['rev-parse', `${tagCommit}^`], cwd) === currentCommit
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function githubJson(fetchImpl, url, token, options = {}) {
|
|
const res = await fetchImpl(url, {
|
|
...options,
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${token}`,
|
|
'X-GitHub-Api-Version': API_VERSION,
|
|
...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 fetchReleases(repo, token, fetchImpl) {
|
|
const releases = await githubJson(
|
|
fetchImpl,
|
|
`https://api.github.com/repos/${repo}/releases?per_page=100`,
|
|
token
|
|
)
|
|
if (!Array.isArray(releases)) {
|
|
throw new Error(`GitHub releases response for ${repo} was not an array`)
|
|
}
|
|
return releases
|
|
}
|
|
|
|
export async function publishCompleteDraftReleases({
|
|
repo,
|
|
token,
|
|
fetchImpl = fetch,
|
|
verifyReleaseAssets = verifyRequiredReleaseAssets,
|
|
isDraftBuiltFromCurrentRef = ({ tag }) => isTagBuiltFromCurrentRef(tag),
|
|
log = console.log
|
|
}) {
|
|
if (!repo) {
|
|
throw new Error('repo is required')
|
|
}
|
|
if (!token) {
|
|
throw new Error('token is required')
|
|
}
|
|
|
|
const releases = await fetchReleases(repo, token, fetchImpl)
|
|
const candidates = releases
|
|
.filter(isReleaseCutDraft)
|
|
.sort((a, b) => new Date(a.created_at ?? 0).getTime() - new Date(b.created_at ?? 0).getTime())
|
|
|
|
const published = []
|
|
const skipped = []
|
|
|
|
for (const release of candidates) {
|
|
const tag = release.tag_name
|
|
if (!(await isDraftBuiltFromCurrentRef({ tag, release }))) {
|
|
const reason = 'tag is not built from the current release ref'
|
|
skipped.push({ tag, reason })
|
|
log(`Skipping stale RC draft release ${tag}: ${reason}`)
|
|
continue
|
|
}
|
|
|
|
try {
|
|
await verifyReleaseAssets({ repo, tag, token })
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message.split('\n')[0] : String(error)
|
|
skipped.push({ tag, reason })
|
|
log(`Skipping incomplete RC draft release ${tag}: ${reason}`)
|
|
continue
|
|
}
|
|
|
|
// Why: only release-cut-authored RC drafts with a complete asset set are
|
|
// resumed here; incomplete drafts stay private for the normal rebuild path.
|
|
await githubJson(
|
|
fetchImpl,
|
|
`https://api.github.com/repos/${repo}/releases/${release.id}`,
|
|
token,
|
|
{
|
|
method: 'PATCH',
|
|
body: JSON.stringify({
|
|
draft: false,
|
|
prerelease: isRcTag(tag)
|
|
})
|
|
}
|
|
)
|
|
published.push(tag)
|
|
log(`Published complete RC draft release ${tag}`)
|
|
}
|
|
|
|
if (published.length === 0 && skipped.length === 0) {
|
|
log('No complete release-cut RC drafts to publish.')
|
|
}
|
|
|
|
return { published, skipped }
|
|
}
|
|
|
|
export function writeGithubOutputs({ published, skipped }, outputPath = process.env.GITHUB_OUTPUT) {
|
|
if (!outputPath) {
|
|
return
|
|
}
|
|
appendFileSync(
|
|
outputPath,
|
|
`${[
|
|
`published_count=${published.length}`,
|
|
`skipped_count=${skipped.length}`,
|
|
`latest_published_tag=${published.at(-1) ?? ''}`,
|
|
`published_tags=${published.join(',')}`,
|
|
`skipped_tags=${skipped.map((item) => item.tag).join(',')}`
|
|
].join('\n')}\n`
|
|
)
|
|
}
|
|
|
|
async function main() {
|
|
const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
|
|
const repo = process.env.GITHUB_REPOSITORY || 'stablyai/orca'
|
|
const result = await publishCompleteDraftReleases({ repo, token })
|
|
writeGithubOutputs(result)
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main().catch((error) => {
|
|
console.error(error.message)
|
|
process.exit(1)
|
|
})
|
|
}
|