From cd2b62ed14fe57f9f2e01e5d97276c1ac6ec1624 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:29:20 -0700 Subject: [PATCH] feat(updater): name hourly releases by version, build number, time, and sha (#11817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: Orca --- .github/workflows/hourly-mac-build.yml | 45 ++++++++++++++-- config/scripts/hourly-build-version.mjs | 51 +++++++++++++++++-- config/scripts/hourly-build-version.test.mjs | 44 +++++++++++++++- src/main/updater-release-builds.test.ts | 23 +++++++++ src/main/updater-release-builds.ts | 6 +++ .../settings/ReleaseChannelSection.tsx | 11 +++- src/shared/release-channel.test.ts | 1 + src/shared/release-channel.ts | 3 ++ 8 files changed, 173 insertions(+), 11 deletions(-) diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index d256faecb..7576fabc6 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -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(" • (?[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. diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index 6ed30a059..8d8dafc22 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -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` + ) } diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index e75951c48..4454ff815 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -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/ + ) + }) +}) diff --git a/src/main/updater-release-builds.test.ts b/src/main/updater-release-builds.test.ts index 2ab08ae62..b9344954a 100644 --- a/src/main/updater-release-builds.test.ts +++ b/src/main/updater-release-builds.test.ts @@ -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) diff --git a/src/main/updater-release-builds.ts b/src/main/updater-release-builds.ts index a7ff6089b..f4f694fac 100644 --- a/src/main/updater-release-builds.ts +++ b/src/main/updater-release-builds.ts @@ -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' diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index 29a7acdfe..491196593 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -31,12 +31,19 @@ const CHANNEL_DESCRIPTIONS: Record = { } 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', diff --git a/src/shared/release-channel.test.ts b/src/shared/release-channel.test.ts index d8e74ee13..dde3109bb 100644 --- a/src/shared/release-channel.test.ts +++ b/src/shared/release-channel.test.ts @@ -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}` }) diff --git a/src/shared/release-channel.ts b/src/shared/release-channel.ts index d95b0b2aa..a700bc719 100644 --- a/src/shared/release-channel.ts +++ b/src/shared/release-channel.ts @@ -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 }