fix(msix): reserve Store revision and correct App Installer descriptors

This commit is contained in:
ethernet
2026-09-07 01:39:40 -04:00
parent 17a81734e8
commit d6cd079966
8 changed files with 169 additions and 19 deletions

View File

@@ -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`,

View File

@@ -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.<minutes-since-stable>` (see
// Sideload canary MSIX versions are `X.Y.Z.<minutes-since-stable>` (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: {

View File

@@ -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')

View File

@@ -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

View File

@@ -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, /<MainBundle\s/)
assert.doesNotMatch(xml, /<MainPackage\s/)
assert.match(xml, /Uri="https:\/\/updates\.example\.com\/win32\/stable\/HermesBundled-0\.3\.0\.0-win\.msixbundle"/)
// The AppInstaller's own Uri is the bundle URL with .appinstaller swapped in.
assert.match(xml, new RegExp(`^<AppInstaller\\n Uri="[^"]+/win32/stable/${base.bundleFilename.replace(/\.msixbundle$/, '.appinstaller')}"`, 'm'))
const descriptor = /<AppInstaller\s+Uri="([^"]+)"/.exec(xml)[1]
assert.equal(descriptor, `${base.baseUrl}/${base.variantChannelPath}/stable.appinstaller`)
const next = buildAppInstaller({ ...base, version: '0.3.1.0', bundleFilename: 'next.msixbundle' })
assert.equal(/<AppInstaller\s+Uri="([^"]+)"/.exec(next)[1], descriptor)
})
test('MainPackage Name equals the package identity; version matches everywhere', () => {
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)
})

View File

@@ -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 = /<Identity\b[^>]*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')
})

View File

@@ -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"]}']

View File

@@ -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 [
'<?xml version="1.0" encoding="utf-8"?>',
@@ -164,7 +167,7 @@ export function buildAppInstaller(o) {
` Uri="${escapeAttr(appinstallerUri)}"`,
` Version="${escapeAttr(o.version)}"`,
' xmlns="http://schemas.microsoft.com/appx/appinstaller/2017/2">',
' <MainPackage',
' <MainBundle',
` Name="${escapeAttr(o.identityName)}"`,
` Publisher="${escapeAttr(OUT_OF_STORE_PUBLISHER)}"`,
` Version="${escapeAttr(o.version)}"`,
@@ -261,6 +264,51 @@ export function canaryBuildMinutes(tag, gitRoot) {
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.
* @param {number} epochSeconds immutable release time in UTC
* @returns {string}
*/
export function storePackageVersionAt(epochSeconds) {
const date = new Date(epochSeconds * 1000)
const year = date.getUTCFullYear()
if (!Number.isInteger(epochSeconds) || !Number.isFinite(date.getTime()) || year < 1000 || year > 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.