diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs index 9eac889a38..8221b33b68 100644 --- a/apps/desktop/electron-builder.config.cjs +++ b/apps/desktop/electron-builder.config.cjs @@ -226,13 +226,10 @@ module.exports = { displayName, publisher: store ? mustStoreMsix(storeMsixWhenStore).publisher : OUT_OF_STORE_PUBLISHER, publisherDisplayName: store ? mustStoreMsix(storeMsixWhenStore).publisherDisplayName : 'Nous Research', - // Sideload canary MSIX versions are `X.Y.Z.` (see - // scripts/msix-shared.mjs). setBuildNumber makes getVersionInWeirdWindowsForm - // use the BUILD_NUMBER env (4th component) instead of hardcoding ".0" — a - // stable build sets no BUILD_NUMBER and stays X.Y.Z.0, a canary build sets - // it via scripts/bundles/desktop.py so App Installer updates over equal - // canary-over-canary versions instead of refusing them. - setBuildNumber: !store && !channelRequest, + // The native quad is the build time (scripts/msix-shared.mjs::nativeQuad), + // baked into the manifest template, so the builder's own build-number + // override would stamp a second, conflicting version. + setBuildNumber: false, // Store versions are baked into a build-time template. App semver and // artifact filenames stay unchanged; the Store reserves revision zero. // Floor Windows 11 22H2. Below build 18307 the manifest schema caps diff --git a/apps/desktop/scripts/gen-msix-manifest.mjs b/apps/desktop/scripts/gen-msix-manifest.mjs index a3cfbbe37a..65bb1248b3 100644 --- a/apps/desktop/scripts/gen-msix-manifest.mjs +++ b/apps/desktop/scripts/gen-msix-manifest.mjs @@ -85,9 +85,8 @@ const manifest = substituteManifestMacros(template, (m) => { switch (m) { case "publisher": return options.publisher case "publisherDisplayName": return options.publisherDisplayName - // Same 4-part derivation the real build uses (msix-shared::appIdentity) — - // a canary shows its minutes-since-stable component here too, so this - // inspection tool never disagrees with what electron-builder shipped. + // Same quad the real build stamps (msix-shared::nativeQuad) — the build + // time, so this inspection tool never disagrees with what shipped. case "version": return appIdentity(desktop, process.env.HERMES_PAYLOAD_TAG).version case "applicationId": return resolvePackageApplicationId(options.applicationId, options.identityName, appInfoName, "MSIX") case "identityName": return resolvePackageIdentityName(options.identityName, appInfoName, "MSIX") diff --git a/apps/desktop/scripts/msix-manifest-roundtrip.test.mjs b/apps/desktop/scripts/msix-manifest-roundtrip.test.mjs index 0b6847ad3d..f223d49d13 100644 --- a/apps/desktop/scripts/msix-manifest-roundtrip.test.mjs +++ b/apps/desktop/scripts/msix-manifest-roundtrip.test.mjs @@ -45,10 +45,9 @@ function makeFakeDesktop(version) { return dir } -function gitMock(tags, stableEpoch) { +function gitMock(epoch) { execFileSync.mockImplementation((cmd, args) => { - if (args[0] === 'tag') return `${tags.join('\n')}\n` - if (args[0] === 'log') return `${stableEpoch}\n` + if (args[0] === 'for-each-ref') return `${epoch}\n` throw new Error(`unexpected git call ${cmd} ${args.join(' ')}`) }) } @@ -73,28 +72,23 @@ beforeEach(() => { execFileSync.mockReset() }) -test('stable tag: appIdentity derivation round-trips into the manifest Version', () => { +test('stable tag: the build-time quad round-trips into the manifest Version', () => { const app = makeFakeDesktop('0.27.1') + gitMock(Math.floor(Date.UTC(2026, 7, 29, 1, 2, 3) / 1000)) const { version, xml } = buildManifest(app, 'v0.27.1') - assert.equal(version, '0.27.1.0') + assert.equal(version, '2026.5761.123.0') assert.equal(identityVersion(xml), version) }) -test('canary tag: minutes-since-stable build number round-trips into the manifest Version', () => { +test('canary tag: the build-time quad round-trips into the manifest Version', () => { const app = makeFakeDesktop('0.27.1') - // Stable v0.27.1 committed 2026-08-01T00:00:00Z; canary cut 2026-08-29T01:02:03Z. - const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000) - gitMock(['v0.27.1', 'v0.27.2-canary.20260829010203'], stableEpoch) const { version, xml } = buildManifest(app, 'v0.27.2-canary.20260829010203') - const expectedMinutes = Math.floor((Date.UTC(2026, 7, 29, 1, 2, 3) - Date.UTC(2026, 7, 1)) / 60000) - assert.equal(version, `0.27.2.${expectedMinutes}`) + assert.equal(version, '2026.5761.123.0') assert.equal(identityVersion(xml), version) }) test('manifest Version components are 16-bit (makeappx rejects anything larger)', () => { const app = makeFakeDesktop('0.27.1') - const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000) - gitMock(['v0.27.1', 'v0.27.2-canary.20260829010203'], stableEpoch) const { xml } = buildManifest(app, 'v0.27.2-canary.20260829010203') for (const part of identityVersion(xml).split('.')) { const n = Number(part) @@ -102,15 +96,9 @@ test('manifest Version components are 16-bit (makeappx rejects anything larger)' } }) -test('a later canary stamps a strictly larger BUILD_NUMBER than an earlier one', () => { - // The build number exists so Windows can order canaries on the same - // base version; monotonicity across the stamp is the actual contract. +test('a later build stamps a strictly larger quad than an earlier one', () => { const app = makeFakeDesktop('0.27.1') - const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000) - const early = 'v0.27.2-canary.20260815080000' - const late = 'v0.27.2-canary.20260829010203' - gitMock([early, late, 'v0.27.1'], stableEpoch) - const earlyBuild = Number(buildManifest(app, early).version.split('.')[3]) - const lateBuild = Number(buildManifest(app, late).version.split('.')[3]) - assert.ok(lateBuild > earlyBuild, `${lateBuild} must exceed ${earlyBuild}`) + const early = Number(buildManifest(app, 'v0.27.2-canary.20260815080000').version.split('.')[1]) + const late = Number(buildManifest(app, 'v0.27.2-canary.20260829010203').version.split('.')[1]) + assert.ok(late > early, `${late} must exceed ${early}`) }) diff --git a/apps/desktop/scripts/msix-shared.test.mjs b/apps/desktop/scripts/msix-shared.test.mjs index 5c094cc423..57e49cfe49 100644 --- a/apps/desktop/scripts/msix-shared.test.mjs +++ b/apps/desktop/scripts/msix-shared.test.mjs @@ -1,7 +1,6 @@ -// msix-shared — the 4-part MSIX version derivation (minutes-since-stable -// for canaries). The git-backed lookup is deterministic here because -// node:child_process.execFileSync is mocked; the math it feeds is the -// contract App Installer compares. +// msix-shared — the native quad (the build time) is the package version for +// stable and canary both. The git-backed lookup is deterministic here because +// node:child_process.execFileSync is mocked. import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' @@ -28,23 +27,19 @@ function makeFakeDesktop(version) { return dir } -function gitMock(tags, stableEpoch) { - execFileSync.mockImplementation((cmd, args) => { - if (args[0] === 'tag') return `${tags.join('\n')}\n` - if (args[0] === 'log') return `${stableEpoch}\n` - throw new Error(`unexpected git call ${cmd} ${args.join(' ')}`) - }) -} - beforeEach(() => { execFileSync.mockReset() }) test('explicit stable tag owns the package version, independent of checkout metadata', () => { const desktop = makeFakeDesktop('0.1.0') + execFileSync.mockImplementation((cmd, args) => { + if (args[0] === 'for-each-ref') return `${Math.floor(Date.UTC(2026, 7, 29, 1, 2, 3) / 1000)}\n` + throw new Error(`unexpected git call ${cmd} ${args.join(' ')}`) + }) try { const { version, fileVersion } = msix.appIdentity(desktop, 'v0.27.1') - assert.equal(version, '0.27.1.0') + assert.equal(version, '2026.5761.123.0') assert.equal(fileVersion, '0.27.1') assert.equal(msix.appIdentity(desktop, '').version, '0.1.0.0') assert.throws(() => msix.appIdentity(desktop, 'v0.27.1-invalid'), /release tag/) @@ -53,39 +48,20 @@ test('explicit stable tag owns the package version, independent of checkout meta } }) -test('canary version is tag base + minutes since the same-minor stable', () => { +test('a stable tag and a canary stamp the same quad when built together', () => { const desktop = makeFakeDesktop('0.27.1') - // Stable v0.27.1 committed 2026-08-01T00:00:00Z; canary cut 2026-08-29T01:02:03Z. - const stableEpoch = Math.floor(Date.UTC(2026, 7, 1) / 1000) - gitMock(['v0.27.1', 'v0.27.2-canary.20260829010203'], stableEpoch) - const { version, fileVersion } = msix.appIdentity(desktop, 'v0.27.2-canary.20260829010203') - const expectedMinutes = Math.floor((Date.UTC(2026, 7, 29, 1, 2, 3) - Date.UTC(2026, 7, 1)) / 60000) - assert.equal(version, `0.27.2.${expectedMinutes}`) - // The artifact FILENAME carries the full canary string (appInfo.version), - // not the 4-part feed version. - assert.equal(fileVersion, '0.27.2-canary.20260829010203') -}) - -test('canaryBuildMinutesFor is pure minutes math', () => { - const base = Date.UTC(2026, 7, 1) / 1000 - const minutes = msix.canaryBuildMinutesFor('v0.27.2-canary.20260829010203', base) - assert.equal(minutes, Math.floor((Date.UTC(2026, 7, 29, 1, 2, 3) - Date.UTC(2026, 7, 1)) / 60000)) -}) - -test('canaryBuildMinutesFor returns null for a stable tag', () => { - assert.equal(msix.canaryBuildMinutesFor('v0.27.1', 0), null) -}) - -test('canaryBuildMinutesFor rejects a stable base older than 45 days', () => { - const base = Date.UTC(2026, 5, 1) / 1000 // 2026-06-01 - assert.throws( - () => msix.canaryBuildMinutesFor('v0.27.2-canary.20260829010203', base), - /45 days|16-bit/i - ) -}) - -test('legacy 8-digit canary stamp still computes minutes (midnight of that day)', () => { - const base = Date.UTC(2026, 7, 1) / 1000 - const minutes = msix.canaryBuildMinutesFor('v0.27.2-canary.20260801', base) - assert.equal(minutes, 0) + const epoch = Math.floor(Date.UTC(2026, 7, 29, 1, 2, 3) / 1000) + execFileSync.mockImplementation((cmd, args) => { + if (args[0] === 'for-each-ref') return `${epoch}\n` + throw new Error(`unexpected git call ${cmd} ${args.join(' ')}`) + }) + try { + const stable = msix.appIdentity(desktop, 'v0.27.1') + const canary = msix.appIdentity(desktop, 'v0.27.2-canary.20260829010203') + assert.equal(stable.version, '2026.5761.123.0') + assert.equal(canary.version, stable.version) + assert.equal(canary.fileVersion, '0.27.2-canary.20260829010203') + } finally { + fs.rmSync(desktop, { recursive: true, force: true }) + } }) diff --git a/apps/desktop/scripts/native-quad.test.mjs b/apps/desktop/scripts/native-quad.test.mjs new file mode 100644 index 0000000000..c27ef34b71 --- /dev/null +++ b/apps/desktop/scripts/native-quad.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import { test } from 'vitest' + +import { nativeQuad } from '../../../scripts/msix-shared.mjs' + +// 2026-09-22T00:14:03Z, worked by hand: day-of-year 264 (2026 is not a leap +// year; Jan 1 is hour 0), so hour-of-year is 264*24 = 6336. The 14th minute +// holds 14*60+3 = 843 seconds. +const EPOCH = Date.parse('2026-09-22T00:14:03Z') / 1000 + +test('a stable release and a canary built at the same instant share one quad', () => { + assert.equal(nativeQuad('v0.21.5', EPOCH), '2026.6336.843.0') + assert.equal(nativeQuad('v0.21.4+canary.20260922T001403Z', EPOCH), '2026.6336.843.0') +}) + +test('a later build sorts above an earlier one, stable or canary', () => { + const earlier = nativeQuad('v0.21.5', EPOCH).split('.').map(Number) + const later = nativeQuad('v0.21.4+canary.20260922T001404Z', EPOCH + 1).split('.').map(Number) + const first = later.findIndex((value, index) => value !== earlier[index]) + assert.ok(first >= 0) + assert.ok(later[first] > earlier[first]) +}) + +test('every field stays inside 16 bits and the revision stays zero', () => { + for (const stamp of ['2026-01-01T00:00:00Z', '2026-12-31T23:59:59Z', '2028-12-31T23:59:59Z']) { + const parts = nativeQuad('v0.21.5', Date.parse(stamp) / 1000).split('.').map(Number) + assert.equal(parts.length, 4) + assert.ok(parts.every(value => value >= 0 && value <= 65535)) + assert.equal(parts[3], 0) + } +}) diff --git a/apps/desktop/scripts/windows-file-version.mjs b/apps/desktop/scripts/windows-file-version.mjs deleted file mode 100644 index 957cede6ca..0000000000 --- a/apps/desktop/scripts/windows-file-version.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// windows-file-version.mjs — the Windows VERSIONINFO quad for a release tag. -// -// Windows VERSIONINFO holds four 16-bit fields, so a canary's semver -// string cannot go in it. resedit splits whatever it is handed on "." and -// clamps each token to [0, 65535], so `0.28.0-canary.20260819171926` -// becomes 0.28.0.65535: "0-canary" parses as NaN and falls to the min, -// and the timestamp saturates at the max. Every canary of a minor then -// shows the same meaningless quad on the Details tab. -// -// The timestamp is the only part worth showing there — the semver line is -// identical across a whole canary series — so pack it as -// yyyy.mmdd.hhmm.ss. Every field stays under 65536, and the quad compares -// in timestamp order, which is the order Windows sorts versions in. -// -// Display metadata only: electron-updater keys on the semver version in -// extraMetadata, never on this quad. - -/** - * The `yyyy.mmdd.hhmm.ss` quad for a canary tag, or null for a stable tag - * (whose version is already four legal fields or fewer, so app-builder-lib's - * own handling is correct). - * - * @param {string} releaseTag A release tag, with the leading `v`. - * @returns {string | null} - */ -export function windowsFileVersion(releaseTag) { - const stamp = /-canary\.(20\d{6}(?:\d{6})?)$/.exec(String(releaseTag))?.[1] - if (!stamp) { - return null - } - - // The legacy date-only shape (YYYYMMDD) is midnight of that day. - const parts = /^(\d{4})(\d{2})(\d{2})(\d{2})?(\d{2})?(\d{2})?$/.exec(stamp) - if (!parts) { - return null - } - - const [, year, month, day, hour, minute, second] = parts - const field = (a, b) => Number(`${a ?? '00'}${b ?? '00'}`) - - return `${Number(year)}.${field(month, day)}.${field(hour, minute)}.${Number(second ?? 0)}` -} diff --git a/apps/desktop/scripts/windows-file-version.test.mjs b/apps/desktop/scripts/windows-file-version.test.mjs deleted file mode 100644 index 835e04f236..0000000000 --- a/apps/desktop/scripts/windows-file-version.test.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import assert from 'node:assert/strict' -import { test } from 'vitest' - -import { windowsFileVersion } from './windows-file-version.mjs' - -test('stable tags leave VERSIONINFO to electron-builder', () => { - assert.equal(windowsFileVersion('v0.20.5'), null) - assert.equal(windowsFileVersion('v0.28.0'), null) -}) - -test('canary tags pack the timestamp into a legal quad', () => { - assert.equal(windowsFileVersion('v0.28.0-canary.20260819171926'), '2026.819.1719.26') - assert.equal(windowsFileVersion('v0.20.5-canary.20260826'), '2026.826.0.0') -}) diff --git a/scripts/bundles/desktop.py b/scripts/bundles/desktop.py index 27be104dae..70dbe46b0f 100644 --- a/scripts/bundles/desktop.py +++ b/scripts/bundles/desktop.py @@ -97,22 +97,13 @@ def _build_prepared(prepared, builder_args: list[str], variant: str | None) -> N run([node, "scripts/build/desktop.mjs", "--source", str(repo), "--icons", str(icons), "--stamp", str(desktop / "build/install-stamp.json"), "--native-deps", str(prepared.native), "--out", str(desktop / "dist")], cwd=repo, env=env) - # Windows file-version and MSIX build-number policy remains with its packager. + # The native quad is the build time, for stable and canary both, so there + # is no per-channel build number to inject. version_args = [] - if sys.platform == "win32": - if request.channel_request is not None: - metadata = {"file": request.channel_request["windowsVersion"], "build": None} - elif request.tag is None: - # The plain version needs no canary build-number override. - metadata = {"file": None, "build": None} - else: - script = "const w=require('./apps/desktop/scripts/windows-file-version.mjs');const m=require('./scripts/msix-shared.mjs');console.log(JSON.stringify({file:w.windowsFileVersion(process.argv[1]),build:process.argv[2]!=='store'&&process.argv[1].includes('-canary.')?m.canaryBuildMinutes(process.argv[1],process.cwd()):null}))" - metadata = json.loads(capture([node, "-e", script, request.tag, variant], repo)) - env.pop("BUILD_NUMBER", None) - if metadata["build"] is not None and variant != "store": - env["BUILD_NUMBER"] = str(metadata["build"]) - if metadata["file"]: - version_args = [f'-c.extraMetadata.shortVersion={metadata["file"]}', f'-c.extraMetadata.shortVersionWindows={metadata["file"]}'] + if sys.platform == "win32" and request.channel_request is None and request.tag is not None: + script = "const m=require('./scripts/msix-shared.mjs');console.log(m.nativeQuad(process.argv[1], Math.floor(Date.now()/1000)))" + quad = capture([node, "-e", script, request.tag], repo).strip() + version_args = [f'-c.extraMetadata.shortVersion={quad}', f'-c.extraMetadata.shortVersionWindows={quad}'] require_source(repo, request.commit) run([node, "scripts/run-electron-builder.mjs", *package_args, *version_args, *builder_args], cwd=desktop, env=packaging_environment(env, os.environ, request.target)) diff --git a/scripts/msix-shared.mjs b/scripts/msix-shared.mjs index 35b7d12c6e..6813b12166 100644 --- a/scripts/msix-shared.mjs +++ b/scripts/msix-shared.mjs @@ -81,12 +81,6 @@ export function contentTypeFor(filename) { const CANARY_TAG_RE = /^v(\d+\.\d+\.\d+)-canary\.(20\d{6}(?:\d{6})?)$/ const STABLE_TAG_RE = /^v\d+\.\d+\.\d+$/ -// MSIX version components are 16-bit (makeappx rejects >65535). Minutes -// since the last stable cross it at 45.5 days; a canary cut more than 45 -// days after its stable is a process failure worth surfacing loudly, not a -// number to clamp (a clamped number would break monotonicity). -const MAX_BUILD_MINUTES = 45 * 24 * 60 - /** * @param {string} stamp YYYYMMDD[HHMMSS] UTC stamp * @returns {number} epoch seconds @@ -98,65 +92,6 @@ function stampToEpoch(stamp) { return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h ?? 0), Number(mi ?? 0), Number(s ?? 0)) / 1000 } -/** - * List git tags matching `pattern`, newest-first (git's -v:refname sort). - * @param {string} gitRoot the repo root - * @param {string} pattern git tag glob, e.g. "v0.27.*" - * @returns {string[]} - */ -export function listGitTags(gitRoot, pattern) { - return execFileSync('git', ['tag', '--list', pattern, '--sort=-v:refname'], { cwd: gitRoot, encoding: 'utf8' }) - .split('\n').filter(Boolean) -} - -/** - * The commit time (epoch seconds) of `tag`, for minutes-since-stable math. - * @param {string} gitRoot the repo root - * @param {string} tag a git tag - * @returns {number} - */ -export function gitTagCommitTime(gitRoot, tag) { - return Number(execFileSync('git', ['log', '-1', '--format=%ct', tag], { cwd: gitRoot, encoding: 'utf8' }).trim()) -} - -/** - * Minutes between a canary tag's UTC stamp and the given stable epoch — - * the MSIX 4th version component. Null for a stable tag; throws when the - * stable base is older than 45 days (16-bit component would overflow). - * @param {string} tag the release tag - * @param {number} stableEpoch stable tag commit time, epoch seconds - * @returns {number | null} - */ -export function canaryBuildMinutesFor(tag, stableEpoch) { - const m = CANARY_TAG_RE.exec(String(tag || '')) - if (!m) return null - const minutes = Math.floor((stampToEpoch(m[2]) - stableEpoch) / 60) - if (minutes < 0) return 0 - if (minutes > MAX_BUILD_MINUTES) { - throw new Error( - `canary ${tag} is ${Math.floor(minutes / 1440)} days past its stable base — ` + - `MSIX versions cap at 16 bits (45 days); cut a stable first` - ) - } - return minutes -} - -/** - * Minutes-since-stable for a canary tag, resolving the stable base from - * the repo's tags on the same major.minor line. - * @param {string} tag the release tag - * @param {string} gitRoot the repo root - * @returns {number | null} - */ -export function canaryBuildMinutes(tag, gitRoot) { - const m = CANARY_TAG_RE.exec(String(tag || '')) - if (!m) return null - const majorMinor = m[1].split('.').slice(0, 2).join('.') - const stable = listGitTags(gitRoot, `v${majorMinor}.*`).find(t => STABLE_TAG_RE.test(t)) - if (!stable) return 0 // degenerate: no stable on this line; build number restarts - return canaryBuildMinutesFor(tag, gitTagCommitTime(gitRoot, stable)) -} - /** Store reserves revision for itself. Keep its package sequence separate * from the app's displayed semver and the sideload update sequence. * Calendar fields retain second precision without an epoch offset. @@ -174,21 +109,43 @@ export function storePackageVersionAt(epochSeconds) { return `${year}.${hourOfYear}.${secondOfHour}.0` } -/** @param {string} tag @param {string} gitRoot */ -export function storePackageVersion(tag, gitRoot) { +/** The native quad for a release, stable or canary: ``year.hourOfYear.secondOfHour.0``. + +One derivation for every Windows consumer. The quad is the build time, so a +later build always sorts above an earlier one and a canary shares its stable's +quad when they are built at the same instant. There is no minutes-since-stable +counter, so there is no 45-day cap. +@param {string} _ref the release ref; accepted so callers name what they stamp +@param {number} epochSeconds the build time, UTC +@returns {string} +*/ +export function nativeQuad(_ref, epochSeconds) { + return storePackageVersionAt(epochSeconds) +} + +/** The build time of a release tag: the stamp embedded in a canary tag, or + * the tag's own creation time for a stable tag. + * @param {string} tag @param {string} gitRoot @returns {number} epoch seconds + */ +function releaseEpoch(tag, gitRoot) { const canary = CANARY_TAG_RE.exec(tag) if (canary) { const epoch = stampToEpoch(canary[2]) const roundtrip = new Date(epoch * 1000).toISOString().replace(/[-:T]/g, '').slice(0, canary[2].length) if (roundtrip !== canary[2]) throw new Error('Invalid canary calendar timestamp') - return storePackageVersionAt(epoch) + return epoch } - if (!STABLE_TAG_RE.test(tag)) throw new Error('A Store build requires an exact release tag') const timestamp = execFileSync('git', ['for-each-ref', '--format=%(creatordate:unix)', `refs/tags/${tag}`], { cwd: gitRoot, encoding: 'utf8' }).trim() if (!/^\d+$/.test(timestamp)) throw new Error(`No immutable release timestamp for ${tag}`) - return storePackageVersionAt(Number(timestamp)) + return Number(timestamp) +} + +/** @param {string} tag @param {string} gitRoot */ +export function storePackageVersion(tag, gitRoot) { + if (!CANARY_TAG_RE.test(tag) && !STABLE_TAG_RE.test(tag)) throw new Error('A Store build requires an exact release tag') + return nativeQuad(tag, releaseEpoch(tag, gitRoot)) } /** The custom template controls package identity, not executable VERSIONINFO. @@ -256,23 +213,18 @@ export function appIdentity(desktopDir, tag = process.env.HERMES_PAYLOAD_TAG || return { identity, version: `${version}.0`, fileVersion: version, name: identity.artifactNamePascal } } if (identity.store) { - return { identity, version: storePackageVersion(String(tag), repoRoot), + return { identity, version: nativeQuad(String(tag), releaseEpoch(String(tag), repoRoot)), fileVersion: String(tag).slice(1), name: identity.artifactNamePascal } } - const canary = CANARY_TAG_RE.exec(String(tag)) - if (canary) { - // Manifest + feed version: tag base (0.27.2) + minutes-since-stable. - // The artifact FILENAME carries electron-builder's appInfo.version — the - // full canary string (HermesBundled-0.27.2-canary.X-win-x64.msix) — so - // callers that look files up by name need that string separately. + if (CANARY_TAG_RE.test(String(tag)) || (tag && STABLE_TAG_RE.test(tag))) { return { identity, - version: `${canary[1]}.${canaryBuildMinutes(String(tag), repoRoot)}`, + version: nativeQuad(String(tag), releaseEpoch(String(tag), repoRoot)), fileVersion: String(tag).slice(1), name: identity.artifactNamePascal, } } - if (tag && !STABLE_TAG_RE.test(tag)) throw new Error(`Invalid release tag: ${tag}`) + if (tag) throw new Error(`Invalid release tag: ${tag}`) // Release builds override Electron's version without rewriting package.json. const version = tag ? tag.slice(1) : pkg.version return { diff --git a/tests/ci/test_protected_canary_publication.py b/tests/ci/test_protected_canary_publication.py index e4eaea7dc9..2060eb5e01 100644 --- a/tests/ci/test_protected_canary_publication.py +++ b/tests/ci/test_protected_canary_publication.py @@ -47,13 +47,12 @@ def canary(tmp_path, r2_server, monkeypatch): (desktop / "product-identity.cjs").symlink_to(ROOT / "apps/desktop/product-identity.cjs") monkeypatch.chdir(clone) identity = channel_releases.product_identity(tag) - minutes = subprocess.check_output([ + windows_version = subprocess.check_output([ "node", "--input-type=module", "-e", - f"import {{canaryBuildMinutesFor}} from {json.dumps((ROOT / 'scripts/msix-shared.mjs').as_uri())};" - f"console.log(canaryBuildMinutesFor({json.dumps(tag)}, Date.UTC(2026, 8, 13) / 1000))", + f"import {{nativeQuad}} from {json.dumps((ROOT / 'scripts/msix-shared.mjs').as_uri())};" + f"console.log(nativeQuad({json.dumps(tag)}, Date.parse('2026-09-13T00:10:00Z') / 1000))", ], text=True).strip() - windows_version = tag[1:].split("-", 1)[0] + "." + minutes - assert windows_version == "0.1.3.10" + assert windows_version == "2026.6120.600.0" tools = tmp_path / "tools" tools.mkdir() driver = tools / "driver.py" @@ -223,7 +222,7 @@ def test_published_canary_workflow_advances_only_after_every_gate(canary, r2_ser assert result.returncode == 0, result.stdout + result.stderr resolved = ChannelReader(env["CLOUDFLARE_R2_PUBLIC_URL"], repository=env["GITHUB_REPOSITORY"]).resolve("canary") assert resolved.manifest is not None - assert resolved.manifest["request"]["windowsVersion"] == "0.1.3.10" + assert resolved.manifest["request"]["windowsVersion"] == "2026.6120.600.0" assert resolved.manifest["request"]["commit"] == env["RELEASE_COMMIT"] assert len(resolved.manifest["packages"]) == 4 # Bootstrap reads actual receipt-bound artifacts and the promoted native feeds, @@ -265,14 +264,14 @@ def test_published_canary_workflow_advances_only_after_every_gate(canary, r2_ser _git("tag", newer_tag, cwd=clone) _git("push", "origin", newer_tag, cwd=clone) newer = {**env, "RELEASE_TAG": newer_tag, "HERMES_PAYLOAD_TAG": newer_tag, - "FIXTURE_WINDOWS_VERSION": "0.1.3.11"} + "FIXTURE_WINDOWS_VERSION": "2026.6120.660.0"} stage_canary(clone, identity, newer, run) release.write_text(json.dumps({**published, "tagName": newer_tag})) result = run(script, **newer) assert result.returncode == 0, result.stdout + result.stderr advanced = ChannelReader(env["CLOUDFLARE_R2_PUBLIC_URL"], repository=env["GITHUB_REPOSITORY"]).resolve("canary") assert advanced.manifest is not None - assert advanced.manifest["request"]["windowsVersion"] == "0.1.3.11" + assert advanced.manifest["request"]["windowsVersion"] == "2026.6120.660.0" assert advanced.terminal["head"]["sequence"] > resolved.terminal["head"]["sequence"] after = dict(r2_server.store) release.write_text(json.dumps(published))