mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
* feat(updater): add an adhoc release channel for branch builds Hourly covers main. This covers everything that is not main yet: a dispatchable macOS build of an unlanded branch, published to stablyai/orca-adhoc, so the team can run an experimental feature for a few days instead of reasoning about it from a diff. Adhoc sits at the bottom of the version order — 'adhoc' < 'hourly' < 'rc' < stable — so no routine check can walk anyone onto somebody's branch; only an explicit pinned jump reaches one. It gets its own repo rather than sharing orca-hourly's, because a branch build must not appear in the list a developer riding main is looking at. Signed and notarized exactly like hourly, for the same reason: macOS anchors a notarized app's TCC grants on identifier + team, so an unnotarized build reads as a new client and silently loses file access under Documents/Desktop/Downloads. Tags stamp to the second rather than the minute. Hourly runs under a concurrency group and cannot overlap itself; adhoc builds are dispatched on demand, so two people cutting from different branches inside one minute is ordinary — and a minute-resolution tag would collide and fail the second build after its whole pack-and-notarize run. Channel-specific behaviour now derives from one DEDICATED_REPO_CHANNELS list: repo mapping, macOS-only support, and UpdateSource. The RPC schema that validates releaseChannelOverride was a hand-copied enum missing the new channel, which would have rejected the override on its way to the main process; it reads the predicate now. * fix(updater): merge the duplicated shared/types import Co-authored-by: Orca <help@stably.ai> * fix(ci): default the adhoc build ref to the dispatch branch The Actions UI puts its own "Use workflow from" branch picker directly above the ref field, and picking a branch there is what most people read as "build this". Making the field optional means the obvious action is also the correct one; naming a branch explicitly still wins, so main's copy of the workflow runs rather than a stale one on an old branch. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
107 lines
4.3 KiB
JavaScript
107 lines
4.3 KiB
JavaScript
import { execFileSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
|
|
|
|
/** Long enough to name a feature, short enough that a picker row stays readable. */
|
|
export const ADHOC_LABEL_MAX_LENGTH = 32
|
|
|
|
/**
|
|
* `1.4.160-adhoc.20260728140533` — UTC to the second, so tags sort
|
|
* chronologically by semver and every build is uniquely versioned.
|
|
*
|
|
* Why seconds when hourly uses minutes: hourly runs under a concurrency group and
|
|
* cannot overlap itself. Adhoc builds are dispatched on demand, so two people
|
|
* cutting from different branches in the same minute is ordinary — and a
|
|
* minute-resolution tag would collide and fail the second build after its whole
|
|
* pack-and-notarize run.
|
|
*/
|
|
export function createAdhocBuildVersion(baseVersion, date) {
|
|
const match = /^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(baseVersion)
|
|
if (!match) {
|
|
throw new Error(`Package version is not valid semver: ${baseVersion}`)
|
|
}
|
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
|
throw new Error('Adhoc build timestamp is invalid.')
|
|
}
|
|
const pad = (value, width = 2) => String(value).padStart(width, '0')
|
|
const stamp = [
|
|
pad(date.getUTCFullYear(), 4),
|
|
pad(date.getUTCMonth() + 1),
|
|
pad(date.getUTCDate()),
|
|
pad(date.getUTCHours()),
|
|
pad(date.getUTCMinutes()),
|
|
pad(date.getUTCSeconds())
|
|
].join('')
|
|
// Why: drop any -rc.N tail, same as hourly. Keeping it would make every adhoc
|
|
// build semver-NEWER than the RC it was cut from, letting an ordinary
|
|
// RC-channel check offer an unreviewed branch build to RC users. Stripping to
|
|
// the base parks adhoc below rc.N, hourly, and stable ('adhoc' sorts first
|
|
// alphabetically), reachable only by an explicit pinned jump.
|
|
return `${match[1]}-adhoc.${stamp}`
|
|
}
|
|
|
|
/**
|
|
* Turns a dispatch input into a label safe to put in a release title.
|
|
*
|
|
* The input is free text from whoever ran the workflow, so it cannot be trusted
|
|
* to stay inside the title's shape: a stray `•` would forge a field separator,
|
|
* and a newline would break the `$GITHUB_OUTPUT` line the workflow parses. Both
|
|
* collapse to `-` here, which is why this replaces rather than rejects.
|
|
*/
|
|
export function normalizeAdhocLabel(label) {
|
|
const cleaned = String(label ?? '')
|
|
// `refs/heads/x` and `origin/x` are what a ref input tends to arrive as; the
|
|
// prefix is noise in a title where every row is already a branch build.
|
|
.replace(/^(?:refs\/heads\/|origin\/)/, '')
|
|
.replace(/[^\p{L}\p{N}._/-]+/gu, ' ')
|
|
.trim()
|
|
.replace(/\s+/g, '-')
|
|
.slice(0, ADHOC_LABEL_MAX_LENGTH)
|
|
// Truncation can land mid-separator, leaving a title ending in `-` or `/`.
|
|
.replace(/[-._/]+$/, '')
|
|
if (!cleaned) {
|
|
throw new Error(`Adhoc label has no usable characters: ${JSON.stringify(label)}`)
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
/**
|
|
* `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release
|
|
* title, shown verbatim in both the GitHub releases list and the build picker.
|
|
*
|
|
* Why the label sits where hourly puts its build number: several adhoc builds
|
|
* from different branches coexist in the channel, so the picker needs the branch
|
|
* to tell them apart. A counter would say nothing about which one to pick.
|
|
*/
|
|
export function formatAdhocReleaseName(version, label, commit, date) {
|
|
return [
|
|
version.split('-')[0],
|
|
normalizeAdhocLabel(label),
|
|
formatReleaseTitleTimestamp(date),
|
|
commit.slice(0, 7)
|
|
].join(' • ')
|
|
}
|
|
|
|
export function getAdhocBuildIdentity(now = new Date(), label = '') {
|
|
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
|
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
|
encoding: 'utf8'
|
|
}).trim()
|
|
const version = createAdhocBuildVersion(packageJson.version, now)
|
|
return {
|
|
commit,
|
|
version,
|
|
label: normalizeAdhocLabel(label),
|
|
name: formatAdhocReleaseName(version, label, commit, now)
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
|
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
|
|
// Consumed by the workflow via $GITHUB_OUTPUT.
|
|
process.stdout.write(
|
|
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
|
|
)
|
|
}
|