feat(updater): name hourly releases by version, build number, time, and sha (#11817)

* feat(updater): name hourly releases by version, build number, time, and sha

Hourly releases were titled with their raw tag
(`v1.4.163-hourly.202607312054`), which reads as one opaque digit run and
does not say which commit it came from.

Title them `1.4.163 • 01 • 07-31 13:54 • e698241` instead, and show that
same string in the in-app build picker by having the picker render the
release's stored name rather than deriving its own label. Composing it in
one place means the two surfaces cannot drift.

The build number is monotonic across the channel. It is read as the
highest number already in use rather than as a count of releases: the
prune step trims to 72, so a count would roll backwards after three days
and reissue numbers. Drafts count toward it — unlike in the freshness
check, which asks whether a commit shipped, this asks whether a number is
free, and a stranded draft still holds one.

Times are Pacific while the tag's stamp stays UTC. The stamp is a sort
key and a local one would repeat an hour at every DST fall-back, making
two distinct builds compare equal; the title is only ever read.

* fix(updater): fail the hourly build when the release name is missing

The workflow checks out `ref: main`, but a workflow_dispatch runs the
workflow file from whatever branch was dispatched. A branch that edits
this step while main still carries the old script produces an empty name
and an untitled release — silent, and only visible once someone opens the
releases page. Verified by hitting exactly that on run 30665586904.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-31 14:29:20 -07:00 committed by GitHub
parent bd490b14aa
commit cd2b62ed14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 173 additions and 11 deletions

View File

@ -170,7 +170,37 @@ jobs:
- name: Compute hourly version
id: hourly
if: steps.freshness.outputs.should_build == 'true'
run: node config/scripts/hourly-build-version.mjs >>"$GITHUB_OUTPUT"
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
run: |
set -euo pipefail
# Why read the highest number off existing titles rather than counting
# releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
# roll backwards after three days and reissue numbers already in use. The
# maximum is always among the retained builds, because pruning drops the
# oldest. `capture(...)?` swallows the non-match on legacy titles, which
# were the raw tag, so the series simply starts at 01.
#
# Why drafts count here but not in the freshness check: that check asks
# "did this commit ship", where a draft is a no. This one asks "is the
# number free", where a stranded draft still holds one.
last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
build_number=$(( last_number + 1 ))
echo "Hourly build number $build_number (previous high: $last_number)"
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
# someone opens the releases page. Fail here instead.
if ! grep -q '^name=' "$RUNNER_TEMP/hourly-identity.txt"; then
echo "::error::hourly-build-version.mjs emitted no release name; this workflow and main's copy of the script are out of sync."
exit 1
fi
cat "$RUNNER_TEMP/hourly-identity.txt" >>"$GITHUB_OUTPUT"
- name: Build app
if: steps.freshness.outputs.should_build == 'true'
@ -189,9 +219,12 @@ jobs:
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: v${{ steps.hourly.outputs.version }}
NAME: ${{ steps.hourly.outputs.name }}
SHA: ${{ steps.freshness.outputs.head_sha }}
run: |
set -euo pipefail
# Kept at 12 even though the title shows 7: the freshness check above
# parses this back out of the body to decide whether main has moved.
short_sha="${SHA:0:12}"
# Why create it up front: electron-builder then uploads into a known tag
# rather than inferring one from package.json.
@ -205,7 +238,7 @@ jobs:
# after the manifest is verified.
gh release create "$TAG" \
--repo "$HOURLY_REPO" \
--title "$TAG" \
--title "$NAME" \
--draft \
--notes "Automated hourly macOS dev build from commit \`$short_sha\`.
@ -276,10 +309,14 @@ jobs:
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
TAG: ${{ steps.release.outputs.tag }}
NAME: ${{ steps.hourly.outputs.name }}
run: |
set -euo pipefail
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease
echo "Published $TAG"
# --title again: electron-builder resolves this draft by tag and may
# rewrite its title on upload. Re-asserting here means the name the
# picker reads is the one composed above, whatever it did in between.
gh release edit "$TAG" --repo "$HOURLY_REPO" --draft=false --prerelease --title "$NAME"
echo "Published $TAG as \"$NAME\""
# Why: a draft left behind by a failed publish is invisible to users but still
# holds its tag name, so the next run for the same minute would collide.

View File

@ -28,16 +28,59 @@ export function createHourlyBuildVersion(baseVersion, date) {
return `${match[1]}-hourly.${stamp}`
}
export function getHourlyBuildIdentity(now = new Date()) {
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
/**
* `1.4.163 • 01 • 07-31 13:54 • e698241` the human-facing release title, shown
* verbatim in both the GitHub releases list and the in-app build picker.
*
* Why Pacific while the tag's stamp stays UTC: the stamp is a sort key, and a
* local one would repeat an hour at every DST fall-back, making two distinct
* builds compare equal. The title is only ever read, so it uses the timezone the
* people reading it are in. The two therefore disagree by the current offset.
*/
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
throw new Error(`Hourly build number must be a positive integer: ${buildNumber}`)
}
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new Error('Hourly build timestamp is invalid.')
}
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone: RELEASE_NAME_TIME_ZONE,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
hourCycle: 'h23'
})
.formatToParts(date)
.map((part) => [part.type, part.value])
)
return [
version.split('-')[0],
String(buildNumber).padStart(2, '0'),
`${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`,
commit.slice(0, 7)
].join(' • ')
}
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
return { commit, version: createHourlyBuildVersion(packageJson.version, now) }
const version = createHourlyBuildVersion(packageJson.version, now)
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getHourlyBuildIdentity()
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(`version=${identity.version}\ncommit=${identity.commit}\n`)
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
)
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { createHourlyBuildVersion } from './hourly-build-version.mjs'
import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs'
import { compareAppVersions } from '../../src/shared/app-version'
describe('createHourlyBuildVersion', () => {
@ -28,3 +28,45 @@ describe('createHourlyBuildVersion', () => {
expect(() => createHourlyBuildVersion('1.4.160', new Date('nope'))).toThrow(/invalid/)
})
})
describe('formatHourlyReleaseName', () => {
const name = (iso, buildNumber = 1, commit = 'e698241abcde') =>
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
it('renders version, number, Pacific timestamp, and short sha', () => {
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241')
})
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
// the offset between them is not a constant. A test pinned to one season would
// pass all summer and start failing in November.
it('follows the Pacific offset across DST', () => {
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241')
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
})
// Why: hour12: false renders midnight as "24" on some ICU builds, which would
// read as an hour that does not exist and sort oddly beside 00:xx.
it('renders midnight as 00, never 24', () => {
expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
})
it('pads to two digits and grows past them', () => {
expect(name('2026-07-31T20:54:00Z', 9)).toContain(' • 09 • ')
expect(name('2026-07-31T20:54:00Z', 42)).toContain(' • 42 • ')
expect(name('2026-07-31T20:54:00Z', 1234)).toContain(' • 1234 • ')
})
it('rejects a build number that is not a positive integer', () => {
expect(() => name('2026-07-31T20:54:00Z', 0)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', -1)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', 1.5)).toThrow(/positive integer/)
expect(() => name('2026-07-31T20:54:00Z', Number.NaN)).toThrow(/positive integer/)
})
it('rejects an invalid timestamp', () => {
expect(() => formatHourlyReleaseName('1.4.163', 1, 'abcdefg', new Date('nope'))).toThrow(
/invalid/
)
})
})

View File

@ -80,6 +80,29 @@ describe('listReleaseBuilds', () => {
expect(builds.map((build) => build.version)).toEqual(['1.4.159'])
})
// Why: the hourly workflow composes the release title and the picker renders it
// verbatim, so the two can never drift. A title that only repeats the tag says
// nothing the version beside it does not, and must not become a picker row
// reading "v1.4.163-hourly.202607311933".
it('keeps a composed release title and drops one that repeats the tag', async () => {
fetchMock.mockResolvedValue(
jsonResponse([
release('v1.4.163-hourly.202607312054', { name: '1.4.163 • 01 • 07-31 13:54 • e698241' }),
release('v1.4.163-hourly.202607311933', { name: 'v1.4.163-hourly.202607311933' }),
release('v1.4.163-hourly.202607311835', { name: ' ' }),
release('v1.4.163-hourly.202607311735', { name: 42 })
])
)
const builds = await listReleaseBuilds('hourly')
expect(builds.map((build) => build.name)).toEqual([
'1.4.163 • 01 • 07-31 13:54 • e698241',
null,
null,
null
])
})
it('surfaces a rate limit as an actionable message', async () => {
fetchMock.mockResolvedValue(jsonResponse(null, { ok: false, status: 403 }))
await expect(listReleaseBuilds('hourly')).rejects.toThrow(/rate limit/i)

View File

@ -22,6 +22,7 @@ export function getReleaseDownloadUrlForRepo(repo: string, tag: string): string
type GitHubReleaseEntry = {
tag_name?: unknown
name?: unknown
draft?: unknown
published_at?: unknown
html_url?: unknown
@ -37,10 +38,15 @@ function parseReleaseEntry(entry: GitHubReleaseEntry, repo: string): ReleaseBuil
if (!isValidVersion(version) || !channel) {
return null
}
// Why null when it merely repeats the tag: GitHub titles an untitled release
// with its tag name, and hourlies predating the naming change were created that
// way too. Neither says anything the version beside it does not.
const name = typeof entry.name === 'string' ? entry.name.trim() : ''
return {
tag,
version,
channel,
name: name && name !== tag ? name : null,
publishedAt: typeof entry.published_at === 'string' ? entry.published_at : null,
releaseUrl:
typeof entry.html_url === 'string'

View File

@ -31,12 +31,19 @@ const CHANNEL_DESCRIPTIONS: Record<ReleaseChannel, string> = {
}
function formatBuildLabel(build: ReleaseBuild): string {
// Why the release's own title wins: the hourly workflow composes it
// (`1.4.163 • 01 • 07-31 13:54 • e698241`), so this row is the same string the
// GitHub releases list shows — one thing to search for in either place, rather
// than two renderings of the same build that have to be matched up by eye.
if (build.name) {
return build.name
}
const stamp = parseHourlyVersionStamp(build.version)
if (!stamp) {
return build.version
}
// Why: an hourly's semver tail is an opaque timestamp; show it as local time so
// "which build was that" is answerable at a glance.
// Fallback for hourlies cut before that naming; retention ages them out in ~3
// days. An hourly's semver tail is an opaque timestamp, so show it as a date.
return `${build.version.split('-')[0]} · ${stamp.toLocaleString(undefined, {
month: 'short',
day: 'numeric',

View File

@ -105,6 +105,7 @@ describe('release channel', () => {
tag: `v${version}`,
version,
channel: 'hourly',
name: null,
publishedAt: null,
releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/v${version}`
})

View File

@ -100,6 +100,9 @@ export type ReleaseBuild = {
tag: string
version: string
channel: ReleaseChannel
/** The release's GitHub title. Null when it is absent or just repeats the tag,
* so the picker can tell "the workflow named this" from "nobody did". */
name: string | null
publishedAt: string | null
releaseUrl: string
}