Files
hermes-agent/scripts/msix-shared.mjs
ethernet b37acb8389 feat(release): dynamic R2-owned channels and preview retirement (rounds 1-2)
Checkpoint before round-3 reduction (two-tier retirement derived from
product identity). Includes:

- R2 channel protocol (release_channels.py, channel-protocol.ts): records,
  builds, manifests, retired channels with pinned destinationHead and
  receiverProtocol; fail-closed readers in both languages
- One shared native manifest/feed writer (scripts/bundles/channel_artifacts.py)
- Scoped/disposable R2 publication, fork isolation before credential
  access, canary bootstrap verification of its own promoted outputs
- Channel source CLI: typed SourceTarget, source-channel resolution,
  retirement downgrade refusal
- Desktop channel resolver/strategy, install-stamp/build-stamp receiver
  ownership (stable-owned S/T candidates), single-flight updater operation
- Cross-package retirement machinery (receiver/host/preservation/
  compatibility/connections/dialog/discovery, backup_migration strict
  snapshots with retained-link inventory, empty-dir preservation,
  connection-collision resolution, URL-credential rejection)
- Native install harness (tests/install/channel-retirement-*) and
  install-e2e retirement jobs
- checkout-source.test.ts transport shim now covers build_opener().open
  (was silently hitting the real network in CI)

Removed secondary certification protocol (channel_qualification.py) per
approved round-2 plan. All focused suites green at checkpoint; native
cross-package journeys unverified (to be deleted in round 3).
2026-09-14 10:26:36 -04:00

285 lines
13 KiB
JavaScript

// Native MSIX identity/version derivation and artifact content types.
// App Installer XML and feed publication belong to scripts.bundles.release_artifacts.
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
/** Build-only structured bridge; never read by the installed runtime.
* @param {NodeJS.ProcessEnv} [env]
* @returns {import('../apps/desktop/electron/install-stamp.js').ChannelBuildRequest | null}
*/
export function channelBuildRequest(env = process.env) {
if (!env._HERMES_CHANNEL_REQUEST_JSON) return null
const value = JSON.parse(env._HERMES_CHANNEL_REQUEST_JSON)
if (env.HERMES_DESKTOP_VARIANT !== 'bundled') throw new Error('Channel builds support only bundled packaging')
if (env.HERMES_BUILD_COMMIT || env.HERMES_PAYLOAD_TAG) throw new Error('Channel request conflicts with commit or tag identity')
// The bundled toolchain already supplies Python. Reuse the authoritative
// validator rather than maintaining a third protocol decoder for packaging.
const validator = [
'import sys',
'sys.path.insert(0, sys.argv[1])',
'from hermes_cli.release_channels import decode_json',
'from scripts.bundles.desktop_prepare import validate_channel_request',
'validate_channel_request(decode_json(sys.stdin.buffer.read()))'
].join('; ')
execFileSync(env.HERMES_PYTHON || 'python', ['-I', '-S', '-c', validator, path.resolve(import.meta.dirname, '..')], {
env, input: env._HERMES_CHANNEL_REQUEST_JSON, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30_000
})
if (env.HERMES_PAYLOAD_VERSION && env.HERMES_PAYLOAD_VERSION !== value.version) throw new Error('Channel package version conflicts with prepared request')
Object.freeze(value.identity)
Object.freeze(value.bundleEnv)
return Object.freeze(value)
}
// The out-of-store MSIX publisher — the ATS signing cert subject, which is
// what Windows compares against the package manifest publisher at install.
// Mirrored from electron-builder.config.cjs so the .appinstaller and the
// manifest can never drift.
export const OUT_OF_STORE_PUBLISHER =
'CN=Nous Research Inc., O=Nous Research Inc., L=Austin, S=Texas, C=US'
// Content-Type for MSIX / App Installer artifacts. Without the right MIME the
// browser cannot hand a clicked .appinstaller / .msixbundle to the OS App
// Installer (it would download as octet-stream instead). Everything else
// stays octet-stream (R2's default) unchanged. Keys match by filename suffix,
// case-insensitively.
const CONTENT_TYPES = require('./release-content-types.json')
/**
* The Content-Type to store for a staged release artifact, if any.
*
* Keys starting with '.' (or containing one, like 'release.gpg') match by
* filename suffix. Extensionless keys (inrelease/release/packages — the apt
* repo metadata) match by exact basename only, so 'foo-release' or
* 'xrelease' never collide with the apt 'Release' file.
* @param {string} filename
* @returns {string | undefined}
*/
export function contentTypeFor(filename) {
const lower = String(filename).toLowerCase()
const base = lower.slice(lower.lastIndexOf('/') + 1)
for (const [key, mime] of Object.entries(CONTENT_TYPES)) {
if (key.includes('.')) {
if (lower.endsWith(key)) return mime
} else if (base === key) {
return mime
}
}
return undefined
}
// The canary tag base + embedded UTC stamp: v0.27.2-canary.20260829034013
// (8- or 14-digit; the shorter legacy form is midnight of that day). The
// base is the next PATCH over the newest stable, so the first three MSIX
// components come from it (0.27.2) and outversion the stable line
// structurally — cross-line monotonicity is free.
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
*/
function stampToEpoch(stamp) {
const parts = /^(\d{4})(\d{2})(\d{2})(\d{2})?(\d{2})?(\d{2})?$/.exec(stamp)
if (!parts) return 0
const [, y, mo, d, h, mi, s] = parts
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.
* @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)
}
/** MsixTarget derives its quad from app semver even when shortVersion is set.
* Bake the admitted quad into the already staged nonstable template instead.
* @param {string} desktopDir
* @param {import('../apps/desktop/electron/install-stamp.js').ChannelBuildRequest} request
*/
export function stageChannelManifest(desktopDir, request) {
const file = path.join(desktopDir, 'build/msix-manifest.xml')
const template = fs.readFileSync(path.join(desktopDir, 'assets/msix-manifest.xml'), 'utf8')
const payload = JSON.parse(fs.readFileSync(path.join(desktopDir, 'build/agent-payload/manifest.json'), 'utf8'))
const { appExecutionAliasApplications } = require(path.join(desktopDir, 'scripts/before-build.mjs'))
const applications = appExecutionAliasApplications(payload.launchers, request.identity)
if (template.split('${version}').length !== 2) throw new Error('Channel MSIX template must have one version macro')
fs.writeFileSync(file, template.replace('${version}', request.windowsVersion)
.replace('</Applications>', `${applications}\n </Applications>`), 'utf8')
// The legacy hook emits one multi-alias extension when artifact/app names agree.
// Channels always use separate applications for distinct CLI entrypoints.
const extensions = path.join(desktopDir, 'build/msix-extensions.xml')
const xml = fs.readFileSync(extensions, 'utf8')
fs.writeFileSync(extensions, xml.replace(/<uap5:Extension\b[^>]*Category="windows\.appExecutionAlias"[^>]*>[\s\S]*?<\/uap5:Extension>/g, ''), 'utf8')
}
/**
* Resolve the app identity for a desktop build from the app dir: the product
* identity + package version. Pure-ish (reads product-identity.cjs and
* package.json from the app dir) so callers on any runner can derive the
* exact feed filename/identity without duplicating the derivation.
*
* @param {string} desktopDir absolute apps/desktop path
* @param {string} [tag] the release tag (defaults to HERMES_PAYLOAD_TAG)
* @returns {{ identity: object, version: string, name: string, fileVersion: string }}
*/
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, '..', '..')
const request = channelBuildRequest()
if (request) {
if (tag) throw new Error('Channel builds must not select a release tag')
return { identity, version: request.windowsVersion, fileVersion: request.version, name: identity.artifactNamePascal }
}
// Commit artifacts retain app semver but do not advance an update channel.
if (process.env.HERMES_BUILD_COMMIT) {
if (tag) throw new Error('Commit-only builds must not set HERMES_PAYLOAD_TAG')
const commit = process.env.HERMES_BUILD_COMMIT
if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('Commit builds require an exact full SHA')
const version = String(process.env.HERMES_PAYLOAD_VERSION || '')
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)
|| version.split('.').some(part => Number(part) > 65535)) {
throw new Error('Commit builds require HERMES_PAYLOAD_VERSION=X.Y.Z with 16-bit fields')
}
if (identity.store) throw new Error('Store packaging requires a stable release tag')
return { identity, version: `${version}.0`, fileVersion: version, name: identity.artifactNamePascal }
}
if (identity.store) {
return { identity, version: storePackageVersion(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.
return {
identity,
version: `${canary[1]}.${canaryBuildMinutes(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}`)
// Release builds override Electron's version without rewriting package.json.
const version = tag ? tag.slice(1) : pkg.version
return {
identity,
version: `${version}.0`,
fileVersion: version,
name: identity.artifactNamePascal,
}
}