fix(msix): reserve Store revision and correct App Installer descriptors
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
69
apps/desktop/scripts/store-package-version.test.mjs
Normal file
69
apps/desktop/scripts/store-package-version.test.mjs
Normal 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')
|
||||
})
|
||||
@@ -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"]}']
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user