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 }