From 5c855b97655bc0eb0095c4cc88f19346899652dd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:48:10 -0700 Subject: [PATCH] feat(updater): restart hourly build numbers per version, restyle the timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The number answers "which build of 1.4.163 is this", so carrying it across versions made it meaningless — 1.4.164 opened at 38 for no reason a reader could see. It now counts titles matching the base version being built, so a version bump restarts the series at 01. Deriving it moves from workflow jq into the script, because the number depends on the base version and only the script knows which base the published tags resolved to. Timestamps go from `07-31 13:54` to `Jul 31, 1:54PM`, still Pacific. Co-authored-by: Orca --- .github/workflows/hourly-mac-build.yml | 20 +++--- config/scripts/adhoc-build-version.mjs | 2 +- config/scripts/adhoc-build-version.test.mjs | 8 +-- config/scripts/hourly-build-version.mjs | 56 +++++++++++++--- config/scripts/hourly-build-version.test.mjs | 66 ++++++++++++++++--- config/scripts/release-title-timestamp.mjs | 16 +++-- .../settings/ReleaseChannelSection.tsx | 2 +- 7 files changed, 129 insertions(+), 41 deletions(-) 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