From d6cd07996653a913495c5359bcdeb3ffafde590b Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 7 Sep 2026 01:39:40 -0400 Subject: [PATCH] fix(msix): reserve Store revision and correct App Installer descriptors --- apps/desktop/BUILDING.md | 15 +++- apps/desktop/electron-builder.config.cjs | 9 +-- apps/desktop/scripts/before-build.mjs | 12 ++++ apps/desktop/scripts/gen-appinstaller.mjs | 2 +- .../desktop/scripts/gen-appinstaller.test.mjs | 14 ++-- .../scripts/store-package-version.test.mjs | 69 +++++++++++++++++++ scripts/bundles/desktop.py | 7 +- scripts/msix-shared.mjs | 60 ++++++++++++++-- 8 files changed, 169 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/scripts/store-package-version.test.mjs diff --git a/apps/desktop/BUILDING.md b/apps/desktop/BUILDING.md index c6ae22e4e9..5ebeeb4780 100644 --- a/apps/desktop/BUILDING.md +++ b/apps/desktop/BUILDING.md @@ -91,10 +91,21 @@ certificate tables, and one dangling table fails the whole package with `uap5`/`desktop4` namespaces needed by the CLI execution aliases, and the `uap3` fragment (declared on its own root) that registers the app as a Windows Copilot hardware key provider. The extensions file -(`build/msix-extensions.xml`) is generated at config-require time by -`writeMsixExtensions()` in `electron-builder.config.cjs`. Keep the manifest +(`build/msix-extensions.xml`) is generated by the `before-build.mjs` hook. +Keep the manifest in sync with app-builder-lib when electron-builder bumps. +Store builds also stage `build/store-msix-manifest.xml` through that hook. +Its package version is `year.hour-of-year.second-of-hour.0` in UTC. The +canary tag timestamp supplies the time; stable tags use their Git creator +time (tagger time for annotated tags, commit time for lightweight tags). +This leaves the fourth component reserved for Microsoft and gives the +first component a nonzero value. Increasing release times increase package +versions, including stable releases after flights. Tags must be immutable +and release times must increase; rerunning a tag deliberately reuses its +package identity. App semver, artifact filenames, and sideload MSIX version +ordering are unchanged. The Store bundle envelope uses the same derivation. + ## Code signing (macOS) The existing after-sign hook owns notarization using `APPLE_API_KEY`, diff --git a/apps/desktop/electron-builder.config.cjs b/apps/desktop/electron-builder.config.cjs index 0bb9461326..05699dde2b 100644 --- a/apps/desktop/electron-builder.config.cjs +++ b/apps/desktop/electron-builder.config.cjs @@ -11,7 +11,6 @@ 'use strict' const fs = require('node:fs') -const path = require('node:path') const feedContract = require('./update-feed.cjs') const { @@ -199,13 +198,15 @@ module.exports = { displayName, publisher: store ? mustStoreMsix(storeMsixWhenStore).publisher : OUT_OF_STORE_PUBLISHER, publisherDisplayName: store ? mustStoreMsix(storeMsixWhenStore).publisherDisplayName : 'Nous Research', - // Canary MSIX versions are `X.Y.Z.` (see + // 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: true, + setBuildNumber: !store, + // 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 // AppExtension Name at 39 chars and Microsoft's own // "com.microsoft.windows.copilotkeyprovider" is 40 (makeappx @@ -218,7 +219,7 @@ module.exports = { // build time (see the comment on the hook) — never at config require // time, so typecheck/test imports don't touch the filesystem. customExtensionsPath: 'build/msix-extensions.xml', - customManifestPath: 'assets/msix-manifest.xml', + customManifestPath: store ? 'build/store-msix-manifest.xml' : 'assets/msix-manifest.xml', showNameOnTiles: true }, linux: { diff --git a/apps/desktop/scripts/before-build.mjs b/apps/desktop/scripts/before-build.mjs index 633620f294..01fa844400 100644 --- a/apps/desktop/scripts/before-build.mjs +++ b/apps/desktop/scripts/before-build.mjs @@ -20,11 +20,13 @@ import fs from 'node:fs' import path from 'node:path' import { createRequire } from 'node:module' +import { appIdentity, storeManifestTemplate } from '../../../scripts/msix-shared.mjs' const require = createRequire(import.meta.url) const { light, + store, displayName, appNamePascal } = require('../product-identity.cjs') @@ -32,6 +34,7 @@ const { export default async function beforeBuild() { stageMsixAssets() writeMsixExtensions() + if (store) stageStoreManifest(path.join(import.meta.dirname, '..'), process.env.HERMES_PAYLOAD_TAG) const payloadDir = path.join(import.meta.dirname, '..', 'build', 'agent-payload') const manifest = path.join(payloadDir, 'manifest.json') @@ -65,6 +68,15 @@ function stageMsixAssets() { } } +export function stageStoreManifest(desktop, tag) { + const template = fs.readFileSync(path.join(desktop, 'assets/msix-manifest.xml'), 'utf8') + const { version } = appIdentity(desktop, tag) + const output = path.join(desktop, 'build/store-msix-manifest.xml') + fs.mkdirSync(path.dirname(output), { recursive: true }) + fs.writeFileSync(output, storeManifestTemplate(template, version), 'utf8') + return output +} + function writeMsixExtensions() { const desktop = path.join(import.meta.dirname, '..') const output = path.join('build', 'msix-extensions.xml') diff --git a/apps/desktop/scripts/gen-appinstaller.mjs b/apps/desktop/scripts/gen-appinstaller.mjs index 8b360350c0..18e6b91199 100644 --- a/apps/desktop/scripts/gen-appinstaller.mjs +++ b/apps/desktop/scripts/gen-appinstaller.mjs @@ -9,7 +9,7 @@ // // The identity comes from product-identity.cjs via scripts/msix-shared.mjs // (the SAME single derivation as the package manifest), so the -// .appinstaller's MainPackage Name/Publisher always match the bundle's +// .appinstaller's MainBundle Name/Publisher always match the bundle's // manifest. `store` has no appinstaller (the Store owns its distribution). // // Pure buildAppInstaller() lives in scripts/msix-shared.mjs and is diff --git a/apps/desktop/scripts/gen-appinstaller.test.mjs b/apps/desktop/scripts/gen-appinstaller.test.mjs index 5ccd61c8c0..b3ac0b9c2f 100644 --- a/apps/desktop/scripts/gen-appinstaller.test.mjs +++ b/apps/desktop/scripts/gen-appinstaller.test.mjs @@ -22,18 +22,22 @@ describe('buildAppInstaller', () => { assert.equal(OUT_OF_STORE_PUBLISHER, 'CN=Nous Research Inc., O=Nous Research Inc., L=Austin, S=Texas, C=US') }) - test('MainPackage URI and the canonical Uri point at the bundle + appinstaller under the feed dir', () => { + test('MainBundle points at the package bytes and self URI stays on the published channel descriptor', () => { const xml = buildAppInstaller(base) + assert.match(xml, / { + test('MainBundle Name equals the package identity; version matches everywhere', () => { const xml = buildAppInstaller(base) assert.match(xml, /Name="NousResearch\.HermesBundled"/) const versionCount = (xml.match(/Version="0\.3\.0\.0"/g) || []).length - // AppInstaller Version + MainPackage Version = 2 occurrences. + // AppInstaller Version + MainBundle Version = 2 occurrences. assert.equal(versionCount, 2) }) diff --git a/apps/desktop/scripts/store-package-version.test.mjs b/apps/desktop/scripts/store-package-version.test.mjs new file mode 100644 index 0000000000..84639ae974 --- /dev/null +++ b/apps/desktop/scripts/store-package-version.test.mjs @@ -0,0 +1,69 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, expect, test } from 'vitest' +import { appIdentity, storeManifestTemplate, storePackageVersion, storePackageVersionAt } from '../../../scripts/msix-shared.mjs' +import { stageStoreManifest } from './before-build.mjs' +import { AppInfo } from '../../../node_modules/app-builder-lib/dist/appInfo.js' +import { substituteManifestMacros } from '../../../node_modules/app-builder-lib/dist/targets/win/winAppUtil.js' + +const roots = [] +afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }) }) +const desktop = fileURLToPath(new URL('../', import.meta.url)) + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'store-manifest-')) + roots.push(root) + const app = path.join(root, 'apps', 'desktop') + fs.mkdirSync(path.join(app, 'assets'), { recursive: true }) + fs.copyFileSync(path.join(desktop, 'assets/msix-manifest.xml'), path.join(app, 'assets/msix-manifest.xml')) + fs.writeFileSync(path.join(app, 'product-identity.cjs'), "module.exports={store:true,appNamePascal:'HermesBundled'}\n") + fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ name: 'hermes', version: '0.27.1' })) + const env = { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.invalid', + GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.invalid', + GIT_AUTHOR_DATE: '2026-09-07T00:18:00Z', GIT_COMMITTER_DATE: '2026-09-07T00:18:00Z' } + for (const args of [['init', '-q'], ['add', '.'], ['-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'], ['tag', 'v0.27.1']]) { + execFileSync('git', args, { cwd: root, env, stdio: 'pipe' }) + } + return { root, app } +} + +test('Store manifest and envelope agree while executable app semver and sideload sequence remain unchanged', () => { + const { app } = fixture() + const tag = 'v0.27.1-canary.20260907001718' + const identity = appIdentity(app, tag) + const staged = fs.readFileSync(stageStoreManifest(app, tag), 'utf8') + const appInfo = new AppInfo({ metadata: { name: 'hermes', version: tag.slice(1) }, config: { buildNumber: '32863' } }, undefined, {}) + const xml = substituteManifestMacros(staged, key => key === 'version' ? appInfo.getVersionInWeirdWindowsForm(false) : `test-${key}`) + const version = /]*Version="([^"]+)"/.exec(xml)[1] + expect(version).toBe(identity.version) + expect(version.split('.')[3]).toBe('0') + expect(Number(version.split('.')[0])).toBeGreaterThan(0) + expect(identity.fileVersion).toBe(tag.slice(1)) + expect(appInfo.version).toBe(tag.slice(1)) + expect(appInfo.getVersionInWeirdWindowsForm(true)).toBe('0.27.1.32863') + const stable = appIdentity(app, 'v0.27.1').version.split('.').map(Number) + expect(stable[2]).toBeGreaterThan(Number(version.split('.')[2])) +}) + +test('Store calendar ordering survives minute, hour, day and year boundaries and rejects reserved revision', () => { + const seconds = [ + '2026-09-07T00:17:18Z', '2026-09-07T00:17:19Z', '2026-09-07T00:18:00Z', + '2026-09-07T01:00:00Z', '2026-09-08T00:00:00Z', '2027-01-01T00:00:00Z' + ].map(value => Date.parse(value) / 1000) + const versions = seconds.map(storePackageVersionAt).map(value => value.split('.').map(Number)) + for (const parts of versions) { + expect(parts[3]).toBe(0) + expect(parts.every(value => value >= 0 && value <= 65535)).toBe(true) + } + for (let i = 1; i < versions.length; i++) { + const first = versions[i].findIndex((value, index) => value !== versions[i - 1][index]) + expect(versions[i][first]).toBeGreaterThan(versions[i - 1][first]) + } + expect(() => storeManifestTemplate('${version}', '0.27.1.32863')).toThrow() + expect(() => storeManifestTemplate('${version}', '0.27.1.0')).toThrow() + expect(() => storePackageVersionAt(NaN)).toThrow() + expect(() => storePackageVersion('v0.27.1-canary.20260231000000', '.')).toThrow('Invalid canary') +}) diff --git a/scripts/bundles/desktop.py b/scripts/bundles/desktop.py index fe3e07db5e..aec26ba005 100644 --- a/scripts/bundles/desktop.py +++ b/scripts/bundles/desktop.py @@ -102,9 +102,10 @@ def build(repo: Path, tag: str, variant: str, builder_args: list[str]) -> None: # Windows file-version and MSIX build-number policy remains with its packager. version_args = [] if sys.platform == "win32": - 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[1].includes('-canary.')?m.canaryBuildMinutes(process.argv[1],process.cwd()):null}))" - metadata = json.loads(capture([node, "-e", script, tag], repo)) - if metadata["build"] is not None: + 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, 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"]}'] diff --git a/scripts/msix-shared.mjs b/scripts/msix-shared.mjs index 823f47faac..ea4489ba40 100644 --- a/scripts/msix-shared.mjs +++ b/scripts/msix-shared.mjs @@ -3,7 +3,7 @@ // and the release job that stages the feed (scripts/stage-msixbundle.mjs). // // The two call sites must agree on every name/URL that Windows keys on — the -// .appinstaller's MainPackage identity and the bundle URI — so the XML +// .appinstaller's MainBundle identity and the bundle URI — so the XML // builder and the version/filename derivations live here, once. import fs from 'node:fs' @@ -151,12 +151,15 @@ function escapeAttr(value) { * identityName: string // package Identity Name (e.g. "NousResearch.HermesBundled") * version: string // 4-part MSIX version, e.g. "1.2.3.0" * bundleFilename: string // the universal .msixbundle filename in the feed dir + * descriptorFilename?: string // defaults to the channel's .appinstaller name * }} o * @returns {string} the .appinstaller XML */ export function buildAppInstaller(o) { - const bundleUrl = `${o.baseUrl}/${o.variantChannelPath.replace(/\/+$/, '')}/${o.bundleFilename}` - const appinstallerUri = bundleUrl.replace(/\.msixbundle$/, '.appinstaller') + const directory = [o.baseUrl.replace(/\/+$/, ''), o.variantChannelPath.replace(/^\/+|\/+$/g, '')].filter(Boolean).join('/') + const bundleUrl = `${directory}/${o.bundleFilename}` + const descriptor = o.descriptorFilename || `${o.variantChannelPath.replace(/\/+$/, '').split('/').pop()}.appinstaller` + const appinstallerUri = `${directory}/${descriptor}` return [ '', @@ -164,7 +167,7 @@ export function buildAppInstaller(o) { ` Uri="${escapeAttr(appinstallerUri)}"`, ` Version="${escapeAttr(o.version)}"`, ' xmlns="http://schemas.microsoft.com/appx/appinstaller/2017/2">', - ' 65535) { + throw new Error('Store package version needs a valid immutable release timestamp') + } + const hourOfYear = Math.floor((date.getTime() - Date.UTC(year, 0, 1)) / 3_600_000) + const secondOfHour = date.getUTCMinutes() * 60 + date.getUTCSeconds() + return `${year}.${hourOfYear}.${secondOfHour}.0` +} + +/** @param {string} tag @param {string} gitRoot */ +export function storePackageVersion(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) + } + 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)) +} + +/** The custom template controls package identity, not executable VERSIONINFO. + * @param {string} template @param {string} version + */ +export function storeManifestTemplate(template, version) { + if (!/^[1-9]\d*\.\d+\.\d+\.0$/.test(version) || version.split('.').some(part => Number(part) > 65535)) { + throw new Error('Store package version must have a nonzero major, 16-bit fields and zero revision') + } + if (template.split('${version}').length !== 2) throw new Error('MSIX template must have one version macro') + return template.replace('${version}', version) +} + /** * Resolve the app identity for a desktop build from the app dir: the product * identity + package version. Pure-ish (reads product-identity.cjs and @@ -275,6 +323,10 @@ export function appIdentity(desktopDir, tag = process.env.HERMES_PAYLOAD_TAG || const identity = require(path.join(desktopDir, 'product-identity.cjs')) const pkg = JSON.parse(fs.readFileSync(path.join(desktopDir, 'package.json'), 'utf8')) const repoRoot = path.resolve(desktopDir, '..', '..') + if (identity.store) { + return { identity, version: storePackageVersion(String(tag), repoRoot), + fileVersion: String(tag).slice(1), name: identity.appNamePascal } + } const canary = CANARY_TAG_RE.exec(String(tag)) if (canary) { // Manifest + feed version: tag base (0.27.2) + minutes-since-stable.