diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index bc3c5a8afff..b8ec40a0058 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -182,20 +182,15 @@ jobs: MAIN_REPO_TOKEN: ${{ github.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. + # Existing titles, which carry the build number this series continues + # from. The script picks the number, because it restarts per base version + # and only the script knows which base this build resolved to. # # 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)" + names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \ + --jq '.[].name // empty')" # Why the main repo's tags decide the base version rather than # package.json: main's version only moves on `release:` commits, and # stable patches are cut from release branches that never merge back, so @@ -207,9 +202,10 @@ jobs: --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ --json tagName --jq '.[].tagName' || true)" echo "Highest published tag seen: $(head -1 <<<"$published")" - ORCA_PUBLISHED_VERSIONS="$published" \ - ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \ + ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \ + node config/scripts/hourly-build-version.mjs \ >"$RUNNER_TEMP/hourly-identity.txt" + grep -E '^(version|build_number)=' "$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 diff --git a/config/scripts/adhoc-build-version.mjs b/config/scripts/adhoc-build-version.mjs index b62f28d1c4f..c5a117f8ed6 100644 --- a/config/scripts/adhoc-build-version.mjs +++ b/config/scripts/adhoc-build-version.mjs @@ -71,7 +71,7 @@ export function normalizeAdhocLabel(label) { } /** - * `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release + * `1.4.163 • wasm-terminal • Aug 1, 2:25PM • 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 diff --git a/config/scripts/adhoc-build-version.test.mjs b/config/scripts/adhoc-build-version.test.mjs index 4824720f2fc..b36ac2cdc6b 100644 --- a/config/scripts/adhoc-build-version.test.mjs +++ b/config/scripts/adhoc-build-version.test.mjs @@ -88,20 +88,20 @@ describe('formatAdhocReleaseName', () => { formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso)) it('renders version, label, Pacific timestamp, and short sha', () => { - expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241') + expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • 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')).toContain(' 01-14 18:30 ') - expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ') + expect(name('2026-01-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ') + expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ') }) it('sanitizes the label before it reaches the title', () => { expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe( - '1.4.163 • fix-now • 07-31 13:54 • e698241' + '1.4.163 • fix-now • Jul 31, 1:54PM • e698241' ) }) diff --git a/config/scripts/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index 12257c2b4ff..9f6b9000844 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -34,8 +34,34 @@ export function createHourlyBuildVersion(baseVersion, date) { } /** - * `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. + * The next build number for `baseVersion`, counting from the titles of existing + * hourly releases. + * + * Why the series restarts at 01 on every base version: the number answers "which + * build of 1.4.163 is this", so a counter shared across versions makes it + * meaningless — 1.4.164 would open at 38 for no reason a reader can see. + * + * Why the maximum rather than a count: the prune step trims to + * HOURLY_RETAIN_COUNT, so a count would roll backwards and reissue a number + * already in use. Titles that predate this naming simply do not match, which is + * how the first build of a version lands on 01. + */ +export function nextHourlyBuildNumber(baseVersion, releaseNames = []) { + const prefix = `${baseVersion} • ` + const highest = releaseNames.reduce((max, entry) => { + const name = String(entry ?? '') + if (!name.startsWith(prefix)) { + return max + } + const match = /^(\d+) • /.exec(name.slice(prefix.length)) + return match ? Math.max(max, Number(match[1])) : max + }, 0) + return highest + 1 +} + +/** + * `1.4.163 • 01 • Jul 31, 1:54PM • e698241` — the human-facing release title, + * shown verbatim in both the GitHub releases list and the in-app build picker. */ export function formatHourlyReleaseName(version, buildNumber, commit, date) { if (!Number.isInteger(buildNumber) || buildNumber < 1) { @@ -49,21 +75,35 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) { ].join(' • ') } -export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) { +// Why the number is derived here rather than passed in: it counts builds of the +// base version, and the base is only known once the published tags have been +// resolved just above. Computing it outside meant numbering against whatever +// version the caller guessed. +export function getHourlyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) { const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { encoding: 'utf8' }).trim() - const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions) + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? []) const version = createHourlyBuildVersion(base, now) - return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) } + const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? []) + return { + commit, + version, + buildNumber, + name: formatHourlyReleaseName(version, buildNumber, commit, now) + } } if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1') - const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv()) + const identity = getHourlyBuildIdentity(new Date(), { + publishedVersions: readPublishedVersionsFromEnv(), + // Titles are newline separated and contain spaces, so this cannot reuse the + // whitespace split the version list gets. + releaseNames: (process.env.ORCA_HOURLY_RELEASE_NAMES ?? '').split('\n').filter(Boolean) + }) // Consumed by the workflow via $GITHUB_OUTPUT. process.stdout.write( - `version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n` + `version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n` ) } diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index 4454ff81512..08fc9d28b81 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs' +import { + createHourlyBuildVersion, + formatHourlyReleaseName, + nextHourlyBuildNumber +} from './hourly-build-version.mjs' import { compareAppVersions } from '../../src/shared/app-version' describe('createHourlyBuildVersion', () => { @@ -34,21 +38,28 @@ describe('formatHourlyReleaseName', () => { 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') + expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • 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') + expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241') + expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • 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 ') + // Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in + // particular renders as 0:00 or 24:00 on a formatter set up carelessly. + it('renders both noon and midnight as 12', () => { + expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM') + expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM') + }) + + // Recent ICU puts U+202F before AM/PM; the title must carry no separator at all. + it('joins the meridiem with no whitespace of any kind', () => { + expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/) + expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u) }) it('pads to two digits and grows past them', () => { @@ -70,3 +81,42 @@ describe('formatHourlyReleaseName', () => { ) }) }) + +describe('nextHourlyBuildNumber', () => { + const titles = [ + '1.4.163 • 01 • Jul 31, 1:54PM • e698241', + '1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa', + '1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb' + ] + + it('continues the series for the version being built', () => { + expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38) + }) + + // The point of the change: 1.4.164 opens its own series at 01 rather than + // inheriting 1.4.163's count, which said nothing about 1.4.164. + it('restarts at 1 when the base version moves', () => { + expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1) + expect( + nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc']) + ).toBe(2) + }) + + // Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting + // would roll backwards and reissue a number already used. + it('takes the highest number, not the count', () => { + expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10) + }) + + it('starts at 1 with no history at all', () => { + expect(nextHourlyBuildNumber('1.4.163')).toBe(1) + expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1) + }) + + // Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as + // a prefix of 1.4.163's series. + it('ignores titles that are not this version', () => { + expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1) + expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1) + }) +}) diff --git a/config/scripts/release-title-timestamp.mjs b/config/scripts/release-title-timestamp.mjs index 56d3608ccaf..64897b4c319 100644 --- a/config/scripts/release-title-timestamp.mjs +++ b/config/scripts/release-title-timestamp.mjs @@ -1,7 +1,7 @@ const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles' /** - * `07-31 13:54` — the timestamp segment of a dev build's release title, shown + * `Jul 31, 8:10PM` — the timestamp segment of a dev build's release title, shown * verbatim in both the GitHub releases list and the in-app build picker. * * Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and @@ -16,15 +16,17 @@ export function formatReleaseTitleTimestamp(date) { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-US', { timeZone: RELEASE_NAME_TIME_ZONE, - month: '2-digit', - day: '2-digit', - hour: '2-digit', + month: 'short', + day: 'numeric', + hour: 'numeric', minute: '2-digit', - // Why h23 rather than hour12: false: some ICU builds render midnight as 24. - hourCycle: 'h23' + hour12: true }) .formatToParts(date) .map((part) => [part.type, part.value]) ) - return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}` + // Assembled from parts rather than by string-editing the formatted output: + // recent ICU separates the time from AM/PM with U+202F, not a plain space, so + // a naive replace(' ', '') leaves the gap on some runtimes and not others. + return `${parts.month} ${parts.day}, ${parts.hour}:${parts.minute}${parts.dayPeriod.toUpperCase()}` } diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index dac9f1b0fa2..b89d8aa5084 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -29,7 +29,7 @@ const CHANNEL_DESCRIPTIONS: Record = { function formatBuildLabel(build: ReleaseBuild): string { // Why the release's own title wins: the build workflows compose it (hourly - // `1.4.163 • 01 • 07-31 13:54 • e698241`, adhoc `1.4.163 • wasm-terminal • …`), + // `1.4.163 • 01 • Jul 31, 1:54PM • e698241`, adhoc `1.4.163 • wasm-terminal • …`), // 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. For adhoc it is also the only place the branch