From efb44fd179fe17d74712bd92ea8666297867f696 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:10:44 +0000 Subject: [PATCH 01/20] feat(common): parse the immutable brand identity artifact v1 --- .../brand-identity-v1-android.json | 15 ++ .../__fixtures__/brand-identity-v1-ios.json | 15 ++ .../brand-identity-v1-zenith-canary.json | 15 ++ .../__fixtures__/brand-identity-v1.json | 15 ++ .../config/__tests__/brand-identity.test.ts | 254 ++++++++++++++++++ .../common/src/config/brand-identity.ts | 186 +++++++++++++ .../foundation/common/src/config/index.ts | 7 + 7 files changed, 507 insertions(+) create mode 100644 packages/foundation/common/src/config/__fixtures__/brand-identity-v1-android.json create mode 100644 packages/foundation/common/src/config/__fixtures__/brand-identity-v1-ios.json create mode 100644 packages/foundation/common/src/config/__fixtures__/brand-identity-v1-zenith-canary.json create mode 100644 packages/foundation/common/src/config/__fixtures__/brand-identity-v1.json create mode 100644 packages/foundation/common/src/config/__tests__/brand-identity.test.ts create mode 100644 packages/foundation/common/src/config/brand-identity.ts diff --git a/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-android.json b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-android.json new file mode 100644 index 00000000..2e8b86aa --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-android.json @@ -0,0 +1,15 @@ +{ + "applicationId": "dev.arcbox.acme", + "assetsPath": "brands/acme", + "brandId": "acme", + "brandIdentityVersion": 1, + "channel": "stable", + "displayName": "Acme Studio", + "platform": "android", + "provenance": { + "manifestSchemaVersion": 1, + "sourceGitSha": "0123456789abcdef0123456789abcdef01234567" + }, + "storageNamespace": "Acme Studio", + "urlScheme": "acme" +} diff --git a/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-ios.json b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-ios.json new file mode 100644 index 00000000..e1c837c8 --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-ios.json @@ -0,0 +1,15 @@ +{ + "applicationId": "dev.arcbox.acme", + "assetsPath": "brands/acme", + "brandId": "acme", + "brandIdentityVersion": 1, + "channel": "stable", + "displayName": "Acme Studio", + "platform": "ios", + "provenance": { + "manifestSchemaVersion": 1, + "sourceGitSha": "0123456789abcdef0123456789abcdef01234567" + }, + "storageNamespace": "Acme Studio", + "urlScheme": "acme" +} diff --git a/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-zenith-canary.json b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-zenith-canary.json new file mode 100644 index 00000000..d3278a9c --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1-zenith-canary.json @@ -0,0 +1,15 @@ +{ + "applicationId": "dev.arcbox.zenith.desktop.canary", + "assetsPath": "brands/zenith", + "brandId": "zenith", + "brandIdentityVersion": 1, + "channel": "canary", + "displayName": "Zenith Workspace Canary", + "platform": "desktop", + "provenance": { + "manifestSchemaVersion": 1, + "sourceGitSha": "0123456789abcdef0123456789abcdef01234567" + }, + "storageNamespace": "Zenith Workspace Canary", + "urlScheme": "zenith-canary" +} diff --git a/packages/foundation/common/src/config/__fixtures__/brand-identity-v1.json b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1.json new file mode 100644 index 00000000..b3382a21 --- /dev/null +++ b/packages/foundation/common/src/config/__fixtures__/brand-identity-v1.json @@ -0,0 +1,15 @@ +{ + "applicationId": "dev.arcbox.acme.desktop", + "assetsPath": "brands/acme", + "brandId": "acme", + "brandIdentityVersion": 1, + "channel": "stable", + "displayName": "Acme Studio", + "platform": "desktop", + "provenance": { + "manifestSchemaVersion": 1, + "sourceGitSha": "0123456789abcdef0123456789abcdef01234567" + }, + "storageNamespace": "Acme Studio", + "urlScheme": "acme" +} diff --git a/packages/foundation/common/src/config/__tests__/brand-identity.test.ts b/packages/foundation/common/src/config/__tests__/brand-identity.test.ts new file mode 100644 index 00000000..c471fa57 --- /dev/null +++ b/packages/foundation/common/src/config/__tests__/brand-identity.test.ts @@ -0,0 +1,254 @@ +import { readFile } from 'node:fs/promises'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { describe, expect, it } from 'vitest'; +import fixture from '../__fixtures__/brand-identity-v1.json'; +import fixtureAndroid from '../__fixtures__/brand-identity-v1-android.json'; +import fixtureIos from '../__fixtures__/brand-identity-v1-ios.json'; +import fixtureZenithCanary from '../__fixtures__/brand-identity-v1-zenith-canary.json'; +import bundleFixture from '../__fixtures__/build-bundle-v1.json'; +import { assertBrandIdentityMatchesBundle, parseBrandIdentityArtifact } from '../brand-identity'; +import { parseConfigBuildBundle } from '../build-bundle'; + +// Frozen publisher fixture bytes (config-publisher fixtures/brand-identity-v1*.json). Never edit +// the vendored copies; re-vendor from the publisher and update the digests together. +const FIXTURE_SHA256 = { + android: '5a3339557869a5ba44c4b43f6c1b401522fe78203431f803b1db2f84a90f0f2d', + desktop: 'a5894d069644597b470551e4c6e1cd53c5b9c19316226b1c3ea1de22607d9dad', + ios: '1e0190690315416b16d5976d3f54847f20efa487e3efd938d9c8d7cc6598cd05', + 'zenith-canary': 'a1990f62f8848f303664859d3b9fd52958854db2aad60be982980dd4a8a64090', +} as const; + +function toHex(bytes: Uint8Array): string { + let hex = ''; + for (const byte of bytes) hex += byte.toString(16).padStart(2, '0'); + return hex; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- tampering requires loose writes +function mutate(change: (artifact: Record) => void): unknown { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tampering requires loose writes + const clone = structuredClone(fixture) as Record; + change(clone); + return clone; +} + +describe('brand identity v1 vendored fixtures', () => { + it('matches the frozen publisher bytes exactly', async () => { + for (const [name, digest] of Object.entries(FIXTURE_SHA256)) { + const suffix = name === 'desktop' ? '' : `-${name}`; + // eslint-disable-next-line no-await-in-loop -- four small reads + const bytes = await readFile( + new URL(`../__fixtures__/brand-identity-v1${suffix}.json`, import.meta.url), + ); + expect(toHex(sha256(bytes)), name).toBe(digest); + } + }); + + it('validates every fixture and preserves its target', () => { + for (const [raw, brandId, platform, channel] of [ + [fixture, 'acme', 'desktop', 'stable'], + [fixtureIos, 'acme', 'ios', 'stable'], + [fixtureAndroid, 'acme', 'android', 'stable'], + [fixtureZenithCanary, 'zenith', 'desktop', 'canary'], + ] as const) { + const identity = parseBrandIdentityArtifact(structuredClone(raw)); + expect(identity.brandId).toBe(brandId); + expect(identity.platform).toBe(platform); + expect(identity.channel).toBe(channel); + } + }); + + it('keeps the two brands disjoint on every OS-visible identifier', () => { + const acme = parseBrandIdentityArtifact(structuredClone(fixture)); + const zenith = parseBrandIdentityArtifact(structuredClone(fixtureZenithCanary)); + expect(acme.applicationId).not.toBe(zenith.applicationId); + expect(acme.urlScheme).not.toBe(zenith.urlScheme); + expect(acme.storageNamespace).not.toBe(zenith.storageNamespace); + expect(acme.assetsPath).not.toBe(zenith.assetsPath); + expect(acme.displayName).not.toBe(zenith.displayName); + }); +}); + +describe('parseBrandIdentityArtifact', () => { + it('rejects structural tampering', () => { + expect(() => parseBrandIdentityArtifact(null)).toThrow('must be an object'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.extra = true; + }), + ), + ).toThrow('unsupported field extra'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + delete artifact.storageNamespace; + }), + ), + ).toThrow('missing field storageNamespace'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.brandIdentityVersion = 2; + }), + ), + ).toThrow('unsupported'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.provenance.extra = true; + }), + ), + ).toThrow('unsupported field extra'); + }); + + it('rejects malformed identifiers, names, paths, and provenance', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tampering requires loose writes + const cases: Array<[(artifact: Record) => void, string]> = [ + [ + (artifact) => { + artifact.brandId = 'Acme'; + }, + 'brandId is invalid', + ], + [ + (artifact) => { + artifact.platform = 'windows'; + }, + 'platform is invalid', + ], + [ + (artifact) => { + artifact.channel = 'beta'; + }, + 'channel is invalid', + ], + [ + (artifact) => { + artifact.applicationId = 'singlesegment'; + }, + 'at least two segments', + ], + [ + (artifact) => { + artifact.applicationId = 'dev..acme'; + }, + 'segment', + ], + [ + (artifact) => { + artifact.displayName = ' Acme'; + }, + 'whitespace', + ], + [ + (artifact) => { + artifact.displayName = 'Acme\u{7}Studio'; + }, + 'control characters', + ], + [ + (artifact) => { + artifact.storageNamespace = 'Acme/Studio'; + }, + 'Windows-reserved', + ], + [ + (artifact) => { + artifact.storageNamespace = 'Acme Studio.'; + }, + 'end with a dot', + ], + [ + (artifact) => { + artifact.urlScheme = '1acme'; + }, + 'urlScheme is invalid', + ], + [ + (artifact) => { + artifact.assetsPath = '/brands/acme'; + }, + 'forward-slash relative path', + ], + [ + (artifact) => { + artifact.assetsPath = 'brands/../acme'; + }, + 'parent segments', + ], + [ + (artifact) => { + artifact.assetsPath = String.raw`brands\acme`; + }, + 'forward-slash relative path', + ], + [ + (artifact) => { + artifact.provenance.sourceGitSha = 'not-a-sha'; + }, + 'sourceGitSha', + ], + [ + (artifact) => { + artifact.provenance.manifestSchemaVersion = 0; + }, + 'manifestSchemaVersion', + ], + ]; + for (const [change, message] of cases) { + expect(() => parseBrandIdentityArtifact(mutate(change))).toThrow(message); + } + }); + + it('enforces platform-specific application id rules', () => { + // Dashes are legal in Apple/desktop ids but never in Android application ids. + const android = structuredClone(fixtureAndroid) as Record; + android.applicationId = 'dev.arc-box.acme'; + expect(() => parseBrandIdentityArtifact(android)).toThrow('invalid for android'); + expect(() => + parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.applicationId = 'dev.arc-box.acme'; + }), + ), + ).not.toThrow(); + }); +}); + +describe('assertBrandIdentityMatchesBundle', () => { + const bundle = parseConfigBuildBundle(structuredClone(bundleFixture)); + + it('accepts an identity for the same target and source commit', () => { + const identity = parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.provenance.sourceGitSha = bundle.provenance.sourceGitSha; + }), + ); + expect(() => assertBrandIdentityMatchesBundle(identity, bundle)).not.toThrow(); + }); + + it('rejects a cross-target identity', () => { + const identity = parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.brandId = 'zenith'; + artifact.provenance.sourceGitSha = bundle.provenance.sourceGitSha; + }), + ); + expect(() => assertBrandIdentityMatchesBundle(identity, bundle)).toThrow( + 'identity targets zenith/desktop/stable', + ); + }); + + it('rejects source commit drift between the two artifacts', () => { + const identity = parseBrandIdentityArtifact( + mutate((artifact) => { + artifact.provenance.sourceGitSha = 'feedfacefeedfacefeedfacefeedfacefeedface'; + }), + ); + expect(identity.provenance.sourceGitSha).not.toBe(bundle.provenance.sourceGitSha); + expect(() => assertBrandIdentityMatchesBundle(identity, bundle)).toThrow( + 'regenerate both from the same pinned commit', + ); + }); +}); diff --git a/packages/foundation/common/src/config/brand-identity.ts b/packages/foundation/common/src/config/brand-identity.ts new file mode 100644 index 00000000..c73ecc59 --- /dev/null +++ b/packages/foundation/common/src/config/brand-identity.ts @@ -0,0 +1,186 @@ +// Client half of the frozen brand identity artifact v1 (publisher CONTRACT.md "Brand identity +// artifact v1"). Validation only — derivation stays in the publisher; never reimplement it here. +import type { ConfigBuildBundle } from './build-bundle'; +import { isRecord } from './contract'; +import type { ConfigChannel, ConfigPlatform } from './types'; +import { CONFIG_CHANNELS, CONFIG_PLATFORMS } from './types'; + +export const BRAND_IDENTITY_VERSION = 1; + +export interface BrandIdentityProvenance { + readonly manifestSchemaVersion: number; + readonly sourceGitSha: string; +} + +/** Resolved build identity for exactly one brand/platform/channel target. Every field is final: + * build tooling consumes it verbatim and never re-derives identity from the brand manifest. */ +export interface BrandIdentityArtifact { + readonly applicationId: string; + readonly assetsPath: string; + readonly brandId: string; + readonly brandIdentityVersion: 1; + readonly channel: ConfigChannel; + readonly displayName: string; + readonly platform: ConfigPlatform; + readonly provenance: BrandIdentityProvenance; + readonly storageNamespace: string; + readonly urlScheme: string; +} + +const ARTIFACT_KEYS = new Set([ + 'applicationId', + 'assetsPath', + 'brandId', + 'brandIdentityVersion', + 'channel', + 'displayName', + 'platform', + 'provenance', + 'storageNamespace', + 'urlScheme', +]); +const PROVENANCE_KEYS = new Set(['manifestSchemaVersion', 'sourceGitSha']); + +const RE_BRAND_ID = /^[a-z][a-z0-9-]{0,62}$/; +const RE_SOURCE_GIT_SHA = /^[0-9a-f]{40}$/; +const RE_URL_SCHEME = /^[a-z][a-z0-9+.-]*$/; +// Android application ids reject dashes and uppercase; Apple/desktop ids allow dashes. +const RE_ANDROID_ID_SEGMENT = /^[a-z][a-z0-9_]*$/; +const RE_APPLE_ID_SEGMENT = /^[a-z][a-z0-9-]*$/i; +// eslint-disable-next-line no-control-regex -- rejecting control characters is the point +const RE_CONTROL_CHARS = /[\u0000-\u001F\u007F]/; +const RE_STORAGE_FORBIDDEN = /[<>:"/\\|?*]/; + +const CONFIG_PLATFORM_SET = new Set(CONFIG_PLATFORMS); +const CONFIG_CHANNEL_SET = new Set(CONFIG_CHANNELS); + +const MAX_DISPLAY_NAME_LENGTH = 80; +const MAX_APPLICATION_ID_LENGTH = 155; + +function fail(message: string): never { + throw new TypeError(message); +} + +function requireExactKeys( + value: Record, + allowed: ReadonlySet, + label: string, +): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail(`${label} contains unsupported field ${key}`); + } + for (const key of allowed) { + if (!(key in value)) fail(`${label} is missing field ${key}`); + } +} + +function assertApplicationId(value: string, platform: ConfigPlatform, label: string): void { + if (value.length > MAX_APPLICATION_ID_LENGTH) fail(`${label} is too long`); + const segments = value.split('.'); + if (segments.length < 2) fail(`${label} must contain at least two segments`); + const segmentRule = platform === 'android' ? RE_ANDROID_ID_SEGMENT : RE_APPLE_ID_SEGMENT; + for (const segment of segments) { + if (!segmentRule.test(segment)) { + fail(`${label} segment ${segment || '(empty)'} is invalid for ${platform}`); + } + } +} + +function assertDisplayName(value: string, label: string): void { + if (value.length === 0 || value.length > MAX_DISPLAY_NAME_LENGTH) { + fail(`${label} must be 1..${MAX_DISPLAY_NAME_LENGTH} characters`); + } + if (RE_CONTROL_CHARS.test(value)) fail(`${label} must not contain control characters`); + if (value !== value.trim()) fail(`${label} must not have leading or trailing whitespace`); +} + +function assertStorageNamespace(value: string, label: string): void { + assertDisplayName(value, label); + if (RE_STORAGE_FORBIDDEN.test(value)) { + fail(`${label} must not contain path or Windows-reserved characters`); + } + if (value.endsWith('.')) fail(`${label} must not end with a dot`); + if (value === '.' || value === '..') fail(`${label} must not be a relative path segment`); +} + +function assertAssetsPath(value: string, label: string): void { + if (value.length === 0) fail(`${label} must not be empty`); + if (value[0] === '/' || value.includes('\\')) { + fail(`${label} must be a forward-slash relative path`); + } + for (const segment of value.split('/')) { + if (segment === '' || segment === '.' || segment === '..') { + fail(`${label} must not contain empty, dot, or parent segments`); + } + } +} + +export function assertBrandIdentityArtifact( + value: unknown, +): asserts value is BrandIdentityArtifact { + if (!isRecord(value)) fail('artifact must be an object'); + requireExactKeys(value, ARTIFACT_KEYS, 'artifact'); + if (value.brandIdentityVersion !== BRAND_IDENTITY_VERSION) { + fail('artifact.brandIdentityVersion is unsupported'); + } + if (typeof value.brandId !== 'string' || !RE_BRAND_ID.test(value.brandId)) { + fail('artifact.brandId is invalid'); + } + if (typeof value.platform !== 'string' || !CONFIG_PLATFORM_SET.has(value.platform)) { + fail('artifact.platform is invalid'); + } + if (typeof value.channel !== 'string' || !CONFIG_CHANNEL_SET.has(value.channel)) { + fail('artifact.channel is invalid'); + } + if (typeof value.applicationId !== 'string') fail('artifact.applicationId must be a string'); + assertApplicationId( + value.applicationId, + value.platform as ConfigPlatform, + 'artifact.applicationId', + ); + if (typeof value.displayName !== 'string') fail('artifact.displayName must be a string'); + assertDisplayName(value.displayName, 'artifact.displayName'); + if (typeof value.storageNamespace !== 'string') { + fail('artifact.storageNamespace must be a string'); + } + assertStorageNamespace(value.storageNamespace, 'artifact.storageNamespace'); + if (typeof value.urlScheme !== 'string' || !RE_URL_SCHEME.test(value.urlScheme)) { + fail('artifact.urlScheme is invalid'); + } + if (typeof value.assetsPath !== 'string') fail('artifact.assetsPath must be a string'); + assertAssetsPath(value.assetsPath, 'artifact.assetsPath'); + if (!isRecord(value.provenance)) fail('artifact.provenance must be an object'); + requireExactKeys(value.provenance, PROVENANCE_KEYS, 'artifact.provenance'); + const { manifestSchemaVersion, sourceGitSha } = value.provenance; + if (!Number.isSafeInteger(manifestSchemaVersion) || (manifestSchemaVersion as number) < 1) { + fail('artifact.provenance.manifestSchemaVersion is invalid'); + } + if (typeof sourceGitSha !== 'string' || !RE_SOURCE_GIT_SHA.test(sourceGitSha)) { + fail('artifact.provenance.sourceGitSha must be a lowercase 40-hex commit'); + } +} + +export function parseBrandIdentityArtifact(value: unknown): BrandIdentityArtifact { + assertBrandIdentityArtifact(value); + return value; +} + +/** A build embeds exactly one identity and one build bundle; both must answer the same target + * from the same manifest commit, or one of them is stale and the build must stop. */ +export function assertBrandIdentityMatchesBundle( + identity: BrandIdentityArtifact, + bundle: ConfigBuildBundle, +): void { + const identityTarget = `${identity.brandId}/${identity.platform}/${identity.channel}`; + const bundleTarget = `${bundle.brandId}/${bundle.platform}/${bundle.channel}`; + if (identityTarget !== bundleTarget) { + fail(`brand identity targets ${identityTarget}, but the build bundle targets ${bundleTarget}`); + } + if (identity.provenance.sourceGitSha !== bundle.provenance.sourceGitSha) { + fail( + `brand identity was rendered from source commit ${identity.provenance.sourceGitSha}, ` + + `but the build bundle came from ${bundle.provenance.sourceGitSha}; ` + + 'regenerate both from the same pinned commit', + ); + } +} diff --git a/packages/foundation/common/src/config/index.ts b/packages/foundation/common/src/config/index.ts index 85ababdf..86558a54 100644 --- a/packages/foundation/common/src/config/index.ts +++ b/packages/foundation/common/src/config/index.ts @@ -1,3 +1,10 @@ +export type { BrandIdentityArtifact, BrandIdentityProvenance } from './brand-identity'; +export { + assertBrandIdentityArtifact, + assertBrandIdentityMatchesBundle, + BRAND_IDENTITY_VERSION, + parseBrandIdentityArtifact, +} from './brand-identity'; export type { ConfigBuildBundle, ConfigBuildBundleEndpoints, From 2f32ac1dce7c689c18fce962857a46928d9bb7e1 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:11:26 +0000 Subject: [PATCH 02/20] feat(common): render brand identity and stage assets through the pinned publisher --- .../src/node/__tests__/brand-assets.test.ts | 161 +++++++++++++ .../__tests__/config-brand-render.test.ts | 213 ++++++++++++++++++ .../common/src/node/brand-assets.ts | 82 +++++++ .../common/src/node/config-brand-render.ts | 129 +++++++++++ .../common/src/node/config-build-render.ts | 84 ++----- packages/foundation/common/src/node/index.ts | 2 + .../common/src/node/render-checkout.ts | 76 +++++++ 7 files changed, 679 insertions(+), 68 deletions(-) create mode 100644 packages/foundation/common/src/node/__tests__/brand-assets.test.ts create mode 100644 packages/foundation/common/src/node/__tests__/config-brand-render.test.ts create mode 100644 packages/foundation/common/src/node/brand-assets.ts create mode 100644 packages/foundation/common/src/node/config-brand-render.ts create mode 100644 packages/foundation/common/src/node/render-checkout.ts diff --git a/packages/foundation/common/src/node/__tests__/brand-assets.test.ts b/packages/foundation/common/src/node/__tests__/brand-assets.test.ts new file mode 100644 index 00000000..6387cd20 --- /dev/null +++ b/packages/foundation/common/src/node/__tests__/brand-assets.test.ts @@ -0,0 +1,161 @@ +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BRAND_ASSET_ICON, stageBrandAssets } from '../brand-assets'; + +const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const RE_ABSENT = /does not exist/; +const RE_ESCAPES_CHECKOUT = /escapes the structural checkout/; +const RE_SYMLINK = /must not be a symlink/; +const RE_NOT_REGULAR_FILE = /regular file/; +const RE_MISSING_ICON = /missing icon\.png/; +const RE_NOT_PNG = /not a PNG file/; +const RE_INVALID_SIZE = /invalid size/; + +let workDir: string; +let outDir: string; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), 'brand-assets-')); + outDir = join(workDir, 'out'); +}); + +afterEach(() => { + rmSync(workDir, { force: true, recursive: true }); +}); + +function makeAssets(brandId: string, iconBody = brandId): string { + const dir = join(workDir, 'structural', 'brands', brandId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, BRAND_ASSET_ICON), Buffer.concat([PNG_MAGIC, Buffer.from(iconBody)])); + return dir; +} + +function structuralDir(): string { + return join(workDir, 'structural'); +} + +describe('stageBrandAssets', () => { + it('stages exactly the selected brand and reports content digests', () => { + makeAssets('acme'); + makeAssets('zenith'); + const staged = stageBrandAssets({ + assetsPath: 'brands/acme', + outDir, + structuralDir: structuralDir(), + }); + expect(staged.map(({ name }) => name)).toEqual([BRAND_ASSET_ICON]); + expect(readdirSync(outDir)).toEqual([BRAND_ASSET_ICON]); + // The staged bytes are the acme bytes, not the zenith bytes. + expect(readFileSync(join(outDir, BRAND_ASSET_ICON)).toString('latin1')).toContain('acme'); + }); + + it('is deterministic: re-staging yields identical bytes and digests', () => { + makeAssets('acme'); + const first = stageBrandAssets({ + assetsPath: 'brands/acme', + outDir, + structuralDir: structuralDir(), + }); + const firstBytes = readFileSync(join(outDir, BRAND_ASSET_ICON)); + const second = stageBrandAssets({ + assetsPath: 'brands/acme', + outDir, + structuralDir: structuralDir(), + }); + expect(second).toEqual(first); + expect(readFileSync(join(outDir, BRAND_ASSET_ICON)).equals(firstBytes)).toBe(true); + }); + + it("replaces a previous brand's output wholesale — no cross-brand leftovers", () => { + makeAssets('acme'); + const zenithDir = makeAssets('zenith'); + writeFileSync( + join(zenithDir, 'extra.png'), + Buffer.concat([PNG_MAGIC, Buffer.from('zenith-extra')]), + ); + stageBrandAssets({ assetsPath: 'brands/zenith', outDir, structuralDir: structuralDir() }); + expect(readdirSync(outDir).sort()).toEqual(['extra.png', BRAND_ASSET_ICON].sort()); + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }); + expect(readdirSync(outDir)).toEqual([BRAND_ASSET_ICON]); + expect(readFileSync(join(outDir, BRAND_ASSET_ICON)).toString('latin1')).toContain('acme'); + }); + + it('rejects a missing assets directory', () => { + expect(() => + stageBrandAssets({ assetsPath: 'brands/ghost', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_ABSENT); + }); + + it('rejects a path that escapes the structural checkout', () => { + makeAssets('acme'); + expect(() => + stageBrandAssets({ + assetsPath: '../escape', + outDir, + structuralDir: join(workDir, 'structural', 'brands'), + }), + ).toThrow(RE_ESCAPES_CHECKOUT); + }); + + it('rejects a symlinked assets directory', () => { + const real = makeAssets('acme'); + symlinkSync(real, join(workDir, 'structural', 'brands', 'evil')); + expect(() => + stageBrandAssets({ assetsPath: 'brands/evil', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_SYMLINK); + }); + + it('rejects a symlinked asset file pointing at another brand', () => { + const acme = makeAssets('acme'); + const zenith = makeAssets('zenith'); + symlinkSync(join(zenith, BRAND_ASSET_ICON), join(acme, 'stolen.png')); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_SYMLINK); + }); + + it('rejects nested directories (asset set v1 is flat)', () => { + const dir = makeAssets('acme'); + mkdirSync(join(dir, 'nested')); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_NOT_REGULAR_FILE); + }); + + it('rejects a directory without the required icon', () => { + const dir = makeAssets('acme'); + rmSync(join(dir, BRAND_ASSET_ICON)); + writeFileSync(join(dir, 'other.png'), Buffer.concat([PNG_MAGIC, Buffer.from('x')])); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_MISSING_ICON); + }); + + it('rejects an icon without PNG magic bytes', () => { + const dir = makeAssets('acme'); + writeFileSync(join(dir, BRAND_ASSET_ICON), Buffer.from('not a png at all')); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_NOT_PNG); + }); + + it('rejects an empty asset file and leaves no partial output behind', () => { + const dir = makeAssets('acme'); + writeFileSync(join(dir, 'empty.txt'), ''); + expect(() => + stageBrandAssets({ assetsPath: 'brands/acme', outDir, structuralDir: structuralDir() }), + ).toThrow(RE_INVALID_SIZE); + // Validation happens before the output directory is touched. + expect(() => readdirSync(outDir)).toThrow(); + }); +}); diff --git a/packages/foundation/common/src/node/__tests__/config-brand-render.test.ts b/packages/foundation/common/src/node/__tests__/config-brand-render.test.ts new file mode 100644 index 00000000..2ae799ca --- /dev/null +++ b/packages/foundation/common/src/node/__tests__/config-brand-render.test.ts @@ -0,0 +1,213 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fixture from '../../config/__fixtures__/brand-identity-v1.json'; +import { parseBrandIdentityArtifact } from '../../config/brand-identity'; +import type { ConfigBrandRenderRequest } from '../config-brand-render'; +import { + assertRenderedIdentityMatches, + configBrandRenderArgs, + renderBrandIdentityWithPublisher, +} from '../config-brand-render'; +import type { RenderCommandRunner } from '../render-checkout'; + +const PUBLISHER_SHA = 'a'.repeat(40); +const fixtureIdentity = parseBrandIdentityArtifact(structuredClone(fixture)); +const SOURCE_SHA = fixtureIdentity.provenance.sourceGitSha; + +const RE_CHECKOUT_ABSENT = /Config publisher checkout not found/; +const RE_WRONG_COMMIT = /is at commit b{40}, but this build pins a{40}/; +const RE_DIRTY_CHECKOUT = /has local modifications/; +const RE_NO_OUTPUT = /did not produce/; +const RE_TARGET_MISMATCH = + /targets acme\/desktop\/stable, but this build requires other-brand\/desktop\/stable/; +const RE_SOURCE_DRIFT = /regenerate from the pinned commit/; +const RE_FIXTURE_TARGET = /targets acme\/desktop\/stable/; + +function headForDir(dir: string): string { + return dir.endsWith('structural') ? SOURCE_SHA : PUBLISHER_SHA; +} + +let workDir: string; + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'config-brand-render-')); +}); + +afterEach(async () => { + await rm(workDir, { force: true, recursive: true }); +}); + +interface RecordedCall { + readonly args: readonly string[]; + readonly command: string; + readonly cwd: string; +} + +function fakeRunner(options: { + head?: (dir: string) => string; + onRender?: () => Promise; + status?: string; +}): { calls: RecordedCall[]; run: RenderCommandRunner } { + const calls: RecordedCall[] = []; + const run: RenderCommandRunner = async (command, args, { cwd }) => { + calls.push({ args, command, cwd }); + if (command === 'git' && args[2] === 'rev-parse') { + const dir = args[1]; + return { stdout: `${options.head?.(dir) ?? headForDir(dir)}\n` }; + } + if (command === 'git' && args[2] === 'status') { + return { stdout: options.status ?? '' }; + } + if (command === 'pnpm') { + await options.onRender?.(); + return { stdout: '' }; + } + throw new Error(`unexpected command ${command}`); + }; + return { calls, run }; +} + +async function makePublisherCheckout(): Promise { + const publisherDir = join(workDir, 'publisher'); + await mkdir(join(publisherDir, 'packages/config-publisher/scripts'), { recursive: true }); + await writeFile(join(publisherDir, 'packages/config-publisher/scripts/brand-render.mts'), ''); + return publisherDir; +} + +function makeRequest(publisherDir: string): ConfigBrandRenderRequest { + return { + brandId: fixtureIdentity.brandId, + channel: fixtureIdentity.channel, + outPath: join(workDir, 'identity.json'), + platform: fixtureIdentity.platform, + publisherDir, + publisherGitSha: PUBLISHER_SHA, + sourceGitSha: SOURCE_SHA, + structuralDir: join(workDir, 'structural'), + }; +} + +describe('renderBrandIdentityWithPublisher', () => { + it('renders through the pinned publisher CLI and validates the output', async () => { + const publisherDir = await makePublisherCheckout(); + const request = makeRequest(publisherDir); + const { calls, run } = fakeRunner({ + onRender: () => writeFile(request.outPath, JSON.stringify(fixture)), + }); + + const identity = await renderBrandIdentityWithPublisher(request, run); + expect(identity.brandId).toBe('acme'); + expect(identity.applicationId).toBe('dev.arcbox.acme.desktop'); + expect(identity.provenance.sourceGitSha).toBe(SOURCE_SHA); + + const render = calls.find((call) => call.command === 'pnpm'); + expect(render).toBeDefined(); + expect(render?.cwd).toBe(publisherDir); + expect(render?.args).toEqual(configBrandRenderArgs(request)); + // Both checkouts were pin-verified and checked for local modifications before rendering. + const gitCalls = calls.filter((call) => call.command === 'git'); + expect(gitCalls).toHaveLength(4); + }); + + it('fails with an actionable error when the publisher checkout is absent', async () => { + const request = makeRequest(join(workDir, 'missing')); + const { calls, run } = fakeRunner({}); + await expect(renderBrandIdentityWithPublisher(request, run)).rejects.toThrow( + RE_CHECKOUT_ABSENT, + ); + expect(calls).toHaveLength(0); + }); + + it('rejects malformed commit pins before touching any checkout', async () => { + const publisherDir = await makePublisherCheckout(); + const { calls, run } = fakeRunner({}); + await expect( + renderBrandIdentityWithPublisher( + { ...makeRequest(publisherDir), publisherGitSha: 'HEAD' }, + run, + ), + ).rejects.toThrow('publisherGitSha must be an exact lowercase 40-hex commit'); + await expect( + renderBrandIdentityWithPublisher( + { ...makeRequest(publisherDir), sourceGitSha: SOURCE_SHA.toUpperCase() }, + run, + ), + ).rejects.toThrow('sourceGitSha must be an exact lowercase 40-hex commit'); + expect(calls).toHaveLength(0); + }); + + it('refuses a publisher checkout at the wrong commit', async () => { + const publisherDir = await makePublisherCheckout(); + const { run } = fakeRunner({ head: () => 'b'.repeat(40) }); + await expect(renderBrandIdentityWithPublisher(makeRequest(publisherDir), run)).rejects.toThrow( + RE_WRONG_COMMIT, + ); + }); + + it('refuses a checkout with local modifications', async () => { + const publisherDir = await makePublisherCheckout(); + const { run } = fakeRunner({ status: ' M packages/config-publisher/src/render.ts\n' }); + await expect(renderBrandIdentityWithPublisher(makeRequest(publisherDir), run)).rejects.toThrow( + RE_DIRTY_CHECKOUT, + ); + }); + + it('fails when the publisher CLI does not produce the output file', async () => { + const publisherDir = await makePublisherCheckout(); + const { run } = fakeRunner({}); + await expect(renderBrandIdentityWithPublisher(makeRequest(publisherDir), run)).rejects.toThrow( + RE_NO_OUTPUT, + ); + }); + + it('fails closed when the rendered output is malformed', async () => { + const publisherDir = await makePublisherCheckout(); + const request = makeRequest(publisherDir); + const tampered = structuredClone(fixture) as { urlScheme: string }; + tampered.urlScheme = 'Not A Scheme'; + const { run } = fakeRunner({ + onRender: () => writeFile(request.outPath, JSON.stringify(tampered)), + }); + await expect(renderBrandIdentityWithPublisher(request, run)).rejects.toThrow( + 'artifact.urlScheme is invalid', + ); + }); + + it('rejects rendered output answering a different request', async () => { + const publisherDir = await makePublisherCheckout(); + const request = { ...makeRequest(publisherDir), brandId: 'other-brand' }; + const { run } = fakeRunner({ + onRender: () => writeFile(request.outPath, JSON.stringify(fixture)), + }); + await expect(renderBrandIdentityWithPublisher(request, run)).rejects.toThrow( + RE_TARGET_MISMATCH, + ); + }); +}); + +describe('assertRenderedIdentityMatches', () => { + const matching = { + brandId: fixtureIdentity.brandId, + channel: fixtureIdentity.channel, + platform: fixtureIdentity.platform, + sourceGitSha: SOURCE_SHA, + } as const; + + it('accepts an identity that answers the request exactly', () => { + expect(() => assertRenderedIdentityMatches(fixtureIdentity, matching)).not.toThrow(); + }); + + it('rejects source commit drift', () => { + expect(() => + assertRenderedIdentityMatches(fixtureIdentity, { ...matching, sourceGitSha: 'c'.repeat(40) }), + ).toThrow(RE_SOURCE_DRIFT); + }); + + it('rejects a channel mismatch', () => { + expect(() => + assertRenderedIdentityMatches(fixtureIdentity, { ...matching, channel: 'canary' }), + ).toThrow(RE_FIXTURE_TARGET); + }); +}); diff --git a/packages/foundation/common/src/node/brand-assets.ts b/packages/foundation/common/src/node/brand-assets.ts new file mode 100644 index 00000000..f4312ef2 --- /dev/null +++ b/packages/foundation/common/src/node/brand-assets.ts @@ -0,0 +1,82 @@ +/// +import { createHash } from 'node:crypto'; +import { copyFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { join, resolve, sep } from 'node:path'; + +/** Brand asset set v1 (publisher CONTRACT.md): a flat directory of regular files that must + * contain icon.png. Mirrors the publisher-side checks — both ends fail closed independently. */ +export const BRAND_ASSET_ICON = 'icon.png'; + +const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const MAX_ASSET_BYTES = 4 * 1024 * 1024; + +export interface StagedBrandAsset { + readonly name: string; + readonly sha256: string; +} + +function fail(message: string): never { + throw new Error(message); +} + +function validatedAssetDir(structuralDir: string, assetsPath: string): string { + const root = resolve(structuralDir); + const dir = resolve(root, assetsPath); + if (dir !== root && !dir.startsWith(root + sep)) { + fail(`brand assets path ${assetsPath} escapes the structural checkout`); + } + let dirStats; + try { + dirStats = lstatSync(dir); + } catch { + fail(`brand assets directory ${assetsPath} does not exist`); + } + if (dirStats.isSymbolicLink()) fail(`brand assets directory ${assetsPath} must not be a symlink`); + if (!dirStats.isDirectory()) fail(`brand assets path ${assetsPath} is not a directory`); + return dir; +} + +/** + * Copies exactly the selected brand's asset set out of the structural checkout into an isolated + * output directory, failing closed on anything a build could mis-ship: symlinks, nested + * directories, empty/oversized files, a missing or non-PNG icon, or a path escaping the + * checkout. The output directory is replaced wholesale so a previous brand's files can never + * survive a re-render. Returns the staged files with content digests, sorted by name. + */ +export function stageBrandAssets(options: { + readonly assetsPath: string; + readonly outDir: string; + readonly structuralDir: string; +}): readonly StagedBrandAsset[] { + const dir = validatedAssetDir(options.structuralDir, options.assetsPath); + const entries = readdirSync(dir).sort(); + const label = options.assetsPath; + for (const entry of entries) { + const stats = lstatSync(join(dir, entry)); + if (stats.isSymbolicLink()) fail(`brand asset ${label}/${entry} must not be a symlink`); + if (!stats.isFile()) { + fail(`brand asset ${label}/${entry} must be a regular file (asset set v1 is flat)`); + } + if (stats.size === 0 || stats.size > MAX_ASSET_BYTES) { + fail(`brand asset ${label}/${entry} has an invalid size`); + } + } + if (!entries.includes(BRAND_ASSET_ICON)) { + fail(`brand assets directory ${label} is missing ${BRAND_ASSET_ICON}`); + } + const icon = readFileSync(join(dir, BRAND_ASSET_ICON)); + if (icon.length < PNG_MAGIC.length || !icon.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) { + fail(`brand asset ${label}/${BRAND_ASSET_ICON} is not a PNG file`); + } + + const outDir = resolve(options.outDir); + rmSync(outDir, { force: true, recursive: true }); + mkdirSync(outDir, { recursive: true }); + return entries.map((name) => { + copyFileSync(join(dir, name), join(outDir, name)); + const sha256 = createHash('sha256') + .update(readFileSync(join(outDir, name))) + .digest('hex'); + return { name, sha256 }; + }); +} diff --git a/packages/foundation/common/src/node/config-brand-render.ts b/packages/foundation/common/src/node/config-brand-render.ts new file mode 100644 index 00000000..ea9a4dd9 --- /dev/null +++ b/packages/foundation/common/src/node/config-brand-render.ts @@ -0,0 +1,129 @@ +/// +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import type { BrandIdentityArtifact } from '../config/brand-identity'; +import { parseBrandIdentityArtifact } from '../config/brand-identity'; +import type { ConfigChannel, ConfigPlatform } from '../config/types'; +import type { RenderCommandRunner } from './render-checkout'; +import { + assertPublisherCheckout, + defaultRenderCommandRunner, + RE_GIT_SHA, + verifyPinnedCheckout, +} from './render-checkout'; + +// Layout of the publisher checkout; the render CLI ships inside the publisher package itself. +const PUBLISHER_PACKAGE_PATH = 'packages/config-publisher'; +const PUBLISHER_SCRIPT_PATH = 'packages/config-publisher/scripts/brand-render.mts'; + +/** Explicit, fully pinned inputs for one brand identity render. Nothing is defaulted or fetched: + * both checkouts must already exist at the exact pinned commits or the render fails closed. */ +export interface ConfigBrandRenderRequest { + readonly brandId: string; + readonly channel: ConfigChannel; + readonly outPath: string; + readonly platform: ConfigPlatform; + /** Root of the config publisher checkout (contains the render CLI). */ + readonly publisherDir: string; + /** Exact commit the publisher checkout must be at. */ + readonly publisherGitSha: string; + /** Exact commit the structural source checkout must be at. */ + readonly sourceGitSha: string; + /** Structural configuration source directory (contains brands.manifest.yaml). */ + readonly structuralDir: string; +} + +/** pnpm arguments that invoke the publisher's own brand-render CLI inside its checkout. */ +export function configBrandRenderArgs(request: ConfigBrandRenderRequest): readonly string[] { + return [ + '--dir', + join(request.publisherDir, PUBLISHER_PACKAGE_PATH), + 'run', + 'brand-render', + '--structural', + request.structuralDir, + '--source-git-sha', + request.sourceGitSha, + '--brand', + request.brandId, + '--platform', + request.platform, + '--channel', + request.channel, + '--out', + request.outPath, + ]; +} + +/** Rejects rendered identity that does not answer this exact request — stale or mismatched + * generated artifacts must stop the build instead of shipping another brand's identity. */ +export function assertRenderedIdentityMatches( + identity: BrandIdentityArtifact, + request: Pick, +): void { + const target = `${identity.brandId}/${identity.platform}/${identity.channel}`; + const expected = `${request.brandId}/${request.platform}/${request.channel}`; + if (target !== expected) { + throw new Error(`Rendered identity targets ${target}, but this build requires ${expected}`); + } + if (identity.provenance.sourceGitSha !== request.sourceGitSha) { + throw new Error( + `Rendered identity was produced from source commit ${identity.provenance.sourceGitSha}, ` + + `but this build pins ${request.sourceGitSha}; regenerate from the pinned commit`, + ); + } +} + +/** Renders one brand identity by invoking the publisher CLI from a pinned checkout, then + * validates the output with the frozen v1 contract. Derivation lives in the publisher only. */ +export async function renderBrandIdentityWithPublisher( + request: ConfigBrandRenderRequest, + run: RenderCommandRunner = defaultRenderCommandRunner, +): Promise { + if (!RE_GIT_SHA.test(request.publisherGitSha)) { + throw new Error('publisherGitSha must be an exact lowercase 40-hex commit'); + } + if (!RE_GIT_SHA.test(request.sourceGitSha)) { + throw new Error('sourceGitSha must be an exact lowercase 40-hex commit'); + } + await assertPublisherCheckout( + request.publisherDir, + request.publisherGitSha, + PUBLISHER_SCRIPT_PATH, + ); + await verifyPinnedCheckout( + request.publisherDir, + request.publisherGitSha, + 'Config publisher', + run, + ); + await verifyPinnedCheckout(request.structuralDir, request.sourceGitSha, 'Config source', run); + + try { + await run('pnpm', configBrandRenderArgs(request), { cwd: request.publisherDir }); + } catch (error) { + const stderr = + typeof error === 'object' && error !== null && 'stderr' in error ? String(error.stderr) : ''; + throw new Error( + `Config publisher brand render failed: ${extractErrorMessage(error)}${stderr ? `\n${stderr}` : ''}`, + { cause: error }, + ); + } + + let text: string; + try { + text = await readFile(request.outPath, 'utf8'); + } catch { + throw new Error(`Config publisher brand render did not produce ${request.outPath}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`Rendered identity at ${request.outPath} is not valid JSON`); + } + const identity = parseBrandIdentityArtifact(parsed); + assertRenderedIdentityMatches(identity, request); + return identity; +} diff --git a/packages/foundation/common/src/node/config-build-render.ts b/packages/foundation/common/src/node/config-build-render.ts index f6034e1b..7afd3437 100644 --- a/packages/foundation/common/src/node/config-build-render.ts +++ b/packages/foundation/common/src/node/config-build-render.ts @@ -1,21 +1,25 @@ /// -import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { readFile, rm, stat } from 'node:fs/promises'; +import { readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { promisify } from 'node:util'; import { extractErrorMessage } from 'foxts/extract-error-message'; import type { ConfigBuildBundle } from '../config/build-bundle'; import { parseConfigBuildBundle } from '../config/build-bundle'; import type { ConfigChannel, ConfigPlatform } from '../config/types'; +import type { RenderCommandResult, RenderCommandRunner } from './render-checkout'; +import { + assertPublisherCheckout, + defaultRenderCommandRunner, + RE_GIT_SHA, + verifyPinnedCheckout, +} from './render-checkout'; -const execFileAsync = promisify(execFile); - -const RE_GIT_SHA = /^[0-9a-f]{40}$/; // Layout of the publisher checkout; the render CLI ships inside the publisher package itself. const PUBLISHER_PACKAGE_PATH = 'packages/config-publisher'; const PUBLISHER_SCRIPT_PATH = 'packages/config-publisher/scripts/build-render.mts'; +export type { RenderCommandResult, RenderCommandRunner }; + /** Explicit, fully pinned inputs for one build-bundle render. Nothing is defaulted or fetched: * both checkouts must already exist at the exact pinned commits or the render fails closed. */ export interface ConfigBuildRenderRequest { @@ -39,24 +43,6 @@ export interface ConfigBuildRenderRequest { readonly telemetryEndpoint: string; } -export interface RenderCommandResult { - readonly stdout: string; -} - -export type RenderCommandRunner = ( - command: string, - args: readonly string[], - options: { readonly cwd: string }, -) => Promise; - -const defaultRunner: RenderCommandRunner = async (command, args, options) => { - const { stdout } = await execFileAsync(command, [...args], { - cwd: options.cwd, - windowsHide: true, - }); - return { stdout }; -}; - /** pnpm arguments that invoke the publisher's own build-render CLI inside its checkout. */ export function configBuildRenderArgs(request: ConfigBuildRenderRequest): readonly string[] { return [ @@ -231,53 +217,11 @@ export function assertRenderedBundleMatches( } } -async function assertPublisherCheckout(dir: string, pinnedSha: string): Promise { - try { - const stats = await stat(join(dir, PUBLISHER_SCRIPT_PATH)); - if (!stats.isFile()) throw new Error('not a file'); - } catch { - throw new Error( - `Config publisher checkout not found at ${dir} (expected ${PUBLISHER_SCRIPT_PATH} inside). ` + - `Check out the config publisher at commit ${pinnedSha} and pass its root explicitly; ` + - 'builds never fall back to a stale or global publisher install', - ); - } -} - -async function verifyPinnedCheckout( - dir: string, - pinnedSha: string, - label: string, - run: RenderCommandRunner, -): Promise { - let head: string; - try { - head = (await run('git', ['-C', dir, 'rev-parse', 'HEAD'], { cwd: dir })).stdout.trim(); - } catch { - throw new Error(`${label} checkout at ${dir} is not a git checkout`); - } - if (head !== pinnedSha) { - throw new Error( - `${label} checkout at ${dir} is at commit ${head}, but this build pins ${pinnedSha}; ` + - 'check out the pinned commit and retry', - ); - } - const status = ( - await run('git', ['-C', dir, 'status', '--porcelain'], { cwd: dir }) - ).stdout.trim(); - if (status.length > 0) { - throw new Error( - `${label} checkout at ${dir} has local modifications; ` + - 'rendered output must come from the pinned commit only', - ); - } -} - /** Renders one build bundle by invoking the publisher CLI from a pinned checkout, then validates * the output with the frozen v1 contract. Rendering semantics live in the publisher only. */ export async function renderConfigBundleWithPublisher( request: ConfigBuildRenderRequest, - run: RenderCommandRunner = defaultRunner, + run: RenderCommandRunner = defaultRenderCommandRunner, ): Promise { if (!RE_GIT_SHA.test(request.publisherGitSha)) { throw new Error('publisherGitSha must be an exact lowercase 40-hex commit'); @@ -285,7 +229,11 @@ export async function renderConfigBundleWithPublisher( if (!RE_GIT_SHA.test(request.sourceGitSha)) { throw new Error('sourceGitSha must be an exact lowercase 40-hex commit'); } - await assertPublisherCheckout(request.publisherDir, request.publisherGitSha); + await assertPublisherCheckout( + request.publisherDir, + request.publisherGitSha, + PUBLISHER_SCRIPT_PATH, + ); await verifyPinnedCheckout( request.publisherDir, request.publisherGitSha, diff --git a/packages/foundation/common/src/node/index.ts b/packages/foundation/common/src/node/index.ts index 4d903418..6c5526f0 100644 --- a/packages/foundation/common/src/node/index.ts +++ b/packages/foundation/common/src/node/index.ts @@ -11,6 +11,8 @@ import { daemonRuntimeFileSegments } from '@linkcode/schema/daemon-runtime'; * bundles. The tsconfig base sets `types: []` — the reference above opts in the Node globals. */ +export * from './brand-assets'; +export * from './config-brand-render'; export * from './config-build-render'; export { executableSearchLocations } from './executable-locations'; export * from './windows-path'; diff --git a/packages/foundation/common/src/node/render-checkout.ts b/packages/foundation/common/src/node/render-checkout.ts new file mode 100644 index 00000000..4fb3204d --- /dev/null +++ b/packages/foundation/common/src/node/render-checkout.ts @@ -0,0 +1,76 @@ +/// +import { execFile } from 'node:child_process'; +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export const RE_GIT_SHA = /^[0-9a-f]{40}$/; + +export interface RenderCommandResult { + readonly stdout: string; +} + +export type RenderCommandRunner = ( + command: string, + args: readonly string[], + options: { readonly cwd: string }, +) => Promise; + +export const defaultRenderCommandRunner: RenderCommandRunner = async (command, args, options) => { + const { stdout } = await execFileAsync(command, [...args], { + cwd: options.cwd, + windowsHide: true, + }); + return { stdout }; +}; + +/** The publisher CLI must exist inside the checkout — builds never fall back to a stale or + * global publisher install. */ +export async function assertPublisherCheckout( + dir: string, + pinnedSha: string, + scriptPath: string, +): Promise { + try { + const stats = await stat(join(dir, scriptPath)); + if (!stats.isFile()) throw new Error('not a file'); + } catch { + throw new Error( + `Config publisher checkout not found at ${dir} (expected ${scriptPath} inside). ` + + `Check out the config publisher at commit ${pinnedSha} and pass its root explicitly; ` + + 'builds never fall back to a stale or global publisher install', + ); + } +} + +/** Rendered output must come from the pinned commit only: exact HEAD, no local modifications. */ +export async function verifyPinnedCheckout( + dir: string, + pinnedSha: string, + label: string, + run: RenderCommandRunner, +): Promise { + let head: string; + try { + head = (await run('git', ['-C', dir, 'rev-parse', 'HEAD'], { cwd: dir })).stdout.trim(); + } catch { + throw new Error(`${label} checkout at ${dir} is not a git checkout`); + } + if (head !== pinnedSha) { + throw new Error( + `${label} checkout at ${dir} is at commit ${head}, but this build pins ${pinnedSha}; ` + + 'check out the pinned commit and retry', + ); + } + const status = ( + await run('git', ['-C', dir, 'status', '--porcelain'], { cwd: dir }) + ).stdout.trim(); + if (status.length > 0) { + throw new Error( + `${label} checkout at ${dir} has local modifications; ` + + 'rendered output must come from the pinned commit only', + ); + } +} From 635dfa24f52f056c86fb4c6abf76c294f0867e17 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:11:55 +0000 Subject: [PATCH 03/20] feat(desktop): derive app identity and isolated storage from the brand artifact --- apps/desktop/scripts/config-bundle.mts | 43 ++++++- apps/desktop/src/env.d.ts | 4 + apps/desktop/src/main/__tests__/brand.test.ts | 106 ++++++++++++++++++ apps/desktop/src/main/brand.ts | 48 ++++++++ apps/desktop/src/main/cloud-auth/client.ts | 12 +- apps/desktop/src/main/constants.ts | 33 +++++- apps/desktop/src/main/identity.ts | 6 +- apps/desktop/vite.main.config.mts | 3 + 8 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/brand.test.ts create mode 100644 apps/desktop/src/main/brand.ts diff --git a/apps/desktop/scripts/config-bundle.mts b/apps/desktop/scripts/config-bundle.mts index 2d6e63eb..e8fe3c42 100644 --- a/apps/desktop/scripts/config-bundle.mts +++ b/apps/desktop/scripts/config-bundle.mts @@ -6,6 +6,9 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { isObjectEmpty } from 'foxts/is-object-empty'; +// Relative on purpose: this module is inlined into the bundled Vite config, which runs under +// plain Node — Node cannot resolve the package's extensionless TS source exports. +import { parseBrandIdentityArtifact } from '../../../packages/foundation/common/src/config/brand-identity'; import { configBuildBundleDefaults, parseConfigBuildBundle, @@ -13,6 +16,9 @@ import { export interface GeneratedConfigBundle { readonly bootstrapJson: string; + /** Present only on white-label renders (config:render --brand-artifacts); the default product + * never has one and keeps its built-in identity. */ + readonly brandIdentityJson?: string; readonly bundleText: string; } @@ -27,6 +33,7 @@ export function loadGeneratedConfigBundle( env: Readonly>>, ): GeneratedConfigBundle | null { const bundlePath = resolve(desktopDir, 'generated/config-build-bundle.json'); + const brandIdentityPath = resolve(desktopDir, 'generated/brand-identity.json'); if (!existsSync(bundlePath)) { if (env.LINKCODE_REQUIRE_CONFIG_BUNDLE === '1') { throw new Error( @@ -34,6 +41,12 @@ export function loadGeneratedConfigBundle( '`pnpm -F @linkcode/desktop config:render` with pinned inputs before building', ); } + if (existsSync(brandIdentityPath)) { + throw new Error( + 'apps/desktop/generated has a brand identity but no config bundle — re-run ' + + '`pnpm -F @linkcode/desktop config:render --brand-artifacts` with pinned inputs', + ); + } return null; } if (env.MAIN_VITE_CONFIG_BOOTSTRAP) { @@ -62,6 +75,34 @@ export function loadGeneratedConfigBundle( 'LINKCODE_REQUIRE_CONFIG_BUNDLE=1 requires an emergency endpoint and emergency public key', ); } + // White-label renders also write the immutable identity artifact; when present it must be the + // same brand/channel/source as the bundle and no ambient override may exist. Deep validation + // (parseBrandIdentityArtifact) throws on any malformed or tampered artifact. + let brandIdentityJson: string | undefined; + if (existsSync(brandIdentityPath)) { + if (env.MAIN_VITE_BRAND_IDENTITY) { + throw new Error( + 'MAIN_VITE_BRAND_IDENTITY must not be set when a generated brand identity exists; ' + + 'the generated brand identity is immutable', + ); + } + brandIdentityJson = readFileSync(brandIdentityPath, 'utf8'); + const identity = parseBrandIdentityArtifact(JSON.parse(brandIdentityJson)); + if (identity.platform !== 'desktop') { + throw new Error(`generated brand identity targets ${identity.platform}, expected desktop`); + } + if ( + identity.brandId !== bundle.brandId || + identity.channel !== bundle.channel || + identity.provenance.sourceGitSha !== bundle.provenance.sourceGitSha + ) { + throw new Error( + `generated brand identity (${identity.brandId}/${identity.channel}) does not match the ` + + `config bundle (${bundle.brandId}/${bundle.channel}) — re-run ` + + '`pnpm -F @linkcode/desktop config:render --brand-artifacts`', + ); + } + } // Same shape as DesktopConfigBootstrap (src/main/config.ts); parseBootstrap revalidates it at // runtime after Vite inlines it into the main bundle. const bootstrap = { @@ -75,7 +116,7 @@ export function loadGeneratedConfigBundle( publicKeys: bundle.keyrings.normal, telemetryEndpoint: bundle.endpoints.telemetry, }; - return { bootstrapJson: JSON.stringify(bootstrap), bundleText }; + return { bootstrapJson: JSON.stringify(bootstrap), brandIdentityJson, bundleText }; } /** diff --git a/apps/desktop/src/env.d.ts b/apps/desktop/src/env.d.ts index 9e6c85ba..9469585f 100644 --- a/apps/desktop/src/env.d.ts +++ b/apps/desktop/src/env.d.ts @@ -7,6 +7,10 @@ interface ImportMetaEnv { * apps/desktop/generated holds a rendered build bundle (config:render), vite.main.config.mts * inlines the derived bootstrap and rejects any ambient env value. */ readonly MAIN_VITE_CONFIG_BOOTSTRAP?: string; + /** Build-time immutable brand identity artifact (config:render, CODE-558); unset builds are + * the default LinkCode identity. Inlined only from generated output — never from ambient env, + * which vite.main.config.ts rejects outright. */ + readonly MAIN_VITE_BRAND_IDENTITY?: string; /** Public PostHog project configuration; both values are required or analytics no-ops. */ readonly RENDERER_VITE_POSTHOG_PROJECT_TOKEN?: string; readonly RENDERER_VITE_POSTHOG_HOST?: string; diff --git a/apps/desktop/src/main/__tests__/brand.test.ts b/apps/desktop/src/main/__tests__/brand.test.ts new file mode 100644 index 00000000..aab50c1a --- /dev/null +++ b/apps/desktop/src/main/__tests__/brand.test.ts @@ -0,0 +1,106 @@ +import { nullthrow } from 'foxts/guard'; +import { describe, expect, it } from 'vitest'; +import { deriveDesktopBrandBase, parseDesktopBrandIdentity } from '../brand'; + +const SOURCE_SHA = '0123456789abcdef0123456789abcdef01234567'; +const RE_DESKTOP = /desktop/; +const RE_DEFAULT_PRODUCT = /linkcode/i; + +function rawIdentity(overrides: Partial> = {}) { + return JSON.stringify({ + applicationId: 'dev.arcbox.acme.desktop', + assetsPath: 'brands/acme', + brandId: 'acme', + brandIdentityVersion: 1, + channel: 'stable', + displayName: 'Acme Studio', + platform: 'desktop', + provenance: { manifestSchemaVersion: 1, sourceGitSha: SOURCE_SHA }, + storageNamespace: 'Acme Studio', + urlScheme: 'acme', + ...overrides, + }); +} + +describe('parseDesktopBrandIdentity', () => { + it('returns null when no identity is inlined', () => { + expect(parseDesktopBrandIdentity(undefined)).toBeNull(); + expect(parseDesktopBrandIdentity('')).toBeNull(); + }); + + it('parses a desktop identity', () => { + const identity = parseDesktopBrandIdentity(rawIdentity()); + expect(identity?.brandId).toBe('acme'); + expect(identity?.applicationId).toBe('dev.arcbox.acme.desktop'); + }); + + it('fails closed on malformed JSON instead of falling back to the default brand', () => { + expect(() => parseDesktopBrandIdentity('{not json')).toThrow(); + }); + + it('fails closed on a non-desktop identity', () => { + expect(() => + parseDesktopBrandIdentity(rawIdentity({ applicationId: 'dev.arcbox.acme', platform: 'ios' })), + ).toThrow(RE_DESKTOP); + }); + + it('fails closed on malformed identity fields', () => { + expect(() => parseDesktopBrandIdentity(rawIdentity({ urlScheme: 'Not A Scheme' }))).toThrow(); + expect(() => + parseDesktopBrandIdentity(rawIdentity({ applicationId: 'no spaces allowed' })), + ).toThrow(); + }); +}); + +describe('deriveDesktopBrandBase', () => { + it('uses the publisher identity verbatim on the release channel', () => { + const identity = nullthrow(parseDesktopBrandIdentity(rawIdentity()), 'expected identity'); + expect(deriveDesktopBrandBase(identity, 'release')).toStrictEqual({ + appId: 'dev.arcbox.acme.desktop', + appName: 'Acme Studio', + authScheme: 'acme', + storageDirName: 'Acme Studio', + }); + }); + + it('decorates the development channel without touching release identity', () => { + const identity = nullthrow(parseDesktopBrandIdentity(rawIdentity()), 'expected identity'); + expect(deriveDesktopBrandBase(identity, 'development')).toStrictEqual({ + appId: 'dev.arcbox.acme.desktop.development', + appName: 'Acme Studio Development', + authScheme: 'acme-dev', + storageDirName: 'Acme Studio Development', + }); + }); + + it('keeps two brands fully isolated on the same channel', () => { + const acme = parseDesktopBrandIdentity(rawIdentity()); + const zenith = parseDesktopBrandIdentity( + rawIdentity({ + applicationId: 'dev.arcbox.zenith.desktop', + assetsPath: 'brands/zenith', + brandId: 'zenith', + displayName: 'Zenith Workspace', + storageNamespace: 'Zenith Workspace', + urlScheme: 'zenith', + }), + ); + if (acme === null || zenith === null) throw new Error('expected identities'); + for (const channel of ['release', 'development'] as const) { + const acmeBase = deriveDesktopBrandBase(acme, channel); + const zenithBase = deriveDesktopBrandBase(zenith, channel); + expect(zenithBase.storageDirName).not.toBe(acmeBase.storageDirName); + expect(zenithBase.appId).not.toBe(acmeBase.appId); + expect(zenithBase.authScheme).not.toBe(acmeBase.authScheme); + } + }); + + it('never derives the legacy unbranded storage location', () => { + const identity = nullthrow(parseDesktopBrandIdentity(rawIdentity()), 'expected identity'); + for (const channel of ['release', 'development'] as const) { + const base = deriveDesktopBrandBase(identity, channel); + expect(base.storageDirName).not.toMatch(RE_DEFAULT_PRODUCT); + expect(base.appId).not.toMatch(RE_DEFAULT_PRODUCT); + } + }); +}); diff --git a/apps/desktop/src/main/brand.ts b/apps/desktop/src/main/brand.ts new file mode 100644 index 00000000..ff84a7ff --- /dev/null +++ b/apps/desktop/src/main/brand.ts @@ -0,0 +1,48 @@ +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import { parseBrandIdentityArtifact } from '@linkcode/common/config'; +import type { ProductChannel } from '@linkcode/schema/daemon-runtime'; + +/** + * The build-time brand identity (CODE-558): rendered by the pinned config publisher, inlined by + * vite.main.config.ts as MAIN_VITE_BRAND_IDENTITY next to the config bootstrap. No identity means + * the default LinkCode build; a present-but-invalid identity aborts boot instead of falling back, + * so a tampered or stale artifact can never ship under the wrong brand. + */ +export function parseDesktopBrandIdentity(raw: string | undefined): BrandIdentityArtifact | null { + if (raw === undefined || raw === '') return null; + const identity = parseBrandIdentityArtifact(JSON.parse(raw)); + if (identity.platform !== 'desktop') { + throw new Error(`brand identity targets ${identity.platform}, not desktop`); + } + return identity; +} + +/** OS-facing base identity before profile suffixing (see constants.ts). */ +export interface DesktopBrandBase { + readonly appId: string; + readonly appName: string; + readonly authScheme: string; + readonly storageDirName: string; +} + +/** + * Applies the client-side development-channel decoration on top of the publisher-rendered + * identity. The publisher already decorated the product channel (canary/stable); the development + * axis is purely local (dev shells, unpackaged runs) and must keep the same isolation rationale + * as the default brand: a development build never clobbers the installed release's settings, + * instance lock, or OS-global URL scheme. + */ +export function deriveDesktopBrandBase( + identity: BrandIdentityArtifact, + channel: ProductChannel, +): DesktopBrandBase { + const development = channel === 'development'; + return { + appId: development ? `${identity.applicationId}.development` : identity.applicationId, + appName: development ? `${identity.displayName} Development` : identity.displayName, + authScheme: development ? `${identity.urlScheme}-dev` : identity.urlScheme, + storageDirName: development + ? `${identity.storageNamespace} Development` + : identity.storageNamespace, + }; +} diff --git a/apps/desktop/src/main/cloud-auth/client.ts b/apps/desktop/src/main/cloud-auth/client.ts index 45e955ce..e8893307 100644 --- a/apps/desktop/src/main/cloud-auth/client.ts +++ b/apps/desktop/src/main/cloud-auth/client.ts @@ -3,7 +3,7 @@ import { electronClient } from '@better-auth/electron/client'; import { createAuthClient } from 'better-auth/client'; import { app, BrowserWindow, ipcMain } from 'electron'; import { CLOUD_CLAIM_DEEP_LINK_CHANNEL } from '../../shared/cloud'; -import { CHANNEL } from '../constants'; +import { CLOUD_AUTH_SCHEME } from '../constants'; import { createSafeStorage } from './storage'; /** @@ -13,12 +13,10 @@ import { createSafeStorage } from './storage'; */ export const CLOUD_API_URL = process.env.LINKCODE_CLOUD_API_URL ?? 'https://api.linkcode.ai'; -/** - * OAuth deep-link protocol, trusted by linkcodehq; split per channel (see constants.ts) so a - * `development` build never fights the installed `release` app over the OS-global scheme — the - * last registrant would win and silently route the callback to the wrong app. - */ -export const CLOUD_AUTH_SCHEME = CHANNEL === 'development' ? 'linkcode-dev' : 'linkcode'; +// The OAuth deep-link scheme is brand identity (CLOUD_AUTH_SCHEME in constants.ts): brand-owned +// on branded builds, channel-split so a development build never fights the release app over the +// OS-global scheme — the last registrant would win and silently route the callback wrong. +export { CLOUD_AUTH_SCHEME } from '../constants'; // Tell the HQ sign-in page which scheme to deep-link back on; the better-auth client appends its // own params with `URL.searchParams.set`, preserving this one. diff --git a/apps/desktop/src/main/constants.ts b/apps/desktop/src/main/constants.ts index 746a7f7f..ecee9192 100644 --- a/apps/desktop/src/main/constants.ts +++ b/apps/desktop/src/main/constants.ts @@ -3,6 +3,7 @@ import { parseProfileName } from '@linkcode/schema/daemon-runtime'; import { workspacesDirName } from '@linkcode/schema/product'; import { app, dialog } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; +import { deriveDesktopBrandBase, parseDesktopBrandIdentity } from './brand'; /** * The desktop identity is two orthogonal axes; every OS-facing surface (app name, `userData`, @@ -39,7 +40,13 @@ function resolveProfile(): string | undefined { /** The requested profile; `undefined` is the default universe (every pre-profile install). */ export const PROFILE = resolveProfile(); -const BASE_NAME = CHANNEL === 'development' ? 'LinkCode Development' : 'LinkCode'; +/** Build-time brand identity (CODE-558): null on default LinkCode builds. A malformed inlined + * artifact throws here and aborts boot — a branded build must never fall back to LinkCode. */ +const BRAND = parseDesktopBrandIdentity(import.meta.env.MAIN_VITE_BRAND_IDENTITY); +const BRAND_BASE = BRAND === null ? null : deriveDesktopBrandBase(BRAND, CHANNEL); + +const BASE_NAME = + BRAND_BASE?.appName ?? (CHANNEL === 'development' ? 'LinkCode Development' : 'LinkCode'); export const APP_NAME = PROFILE === undefined ? BASE_NAME : `${BASE_NAME} (${PROFILE})`; @@ -49,12 +56,32 @@ export const APP_NAME = PROFILE === undefined ? BASE_NAME : `${BASE_NAME} (${PRO * profile get distinct ids for the same isolation rationale as `APP_NAME`. */ const BASE_ID = - CHANNEL === 'development' + BRAND_BASE?.appId ?? + (CHANNEL === 'development' ? 'com.arcboxlabs.linkcode.desktop.development' - : 'com.arcboxlabs.linkcode.desktop'; + : 'com.arcboxlabs.linkcode.desktop'); export const APP_ID = PROFILE === undefined ? BASE_ID : `${BASE_ID}.${PROFILE}`; +/** + * The brand's on-disk storage universe (`userData` directory name, see identity.ts). Without a + * brand this is exactly APP_NAME — the pre-CODE-558 value, so existing installs keep their data. + * With a brand it is the publisher's storageNamespace (channel/profile-forked like APP_NAME); + * each brand only ever resolves its own namespace and never migrates or reads another's. + */ +const STORAGE_BASE = BRAND_BASE?.storageDirName ?? BASE_NAME; + +export const STORAGE_DIR_NAME = + PROFILE === undefined ? STORAGE_BASE : `${STORAGE_BASE} (${PROFILE})`; + +/** + * OAuth deep-link protocol (see cloud-auth/client.ts): brand-owned when a brand identity is + * embedded, split per channel so a development build never fights the installed release over + * the OS-global scheme. + */ +export const CLOUD_AUTH_SCHEME = + BRAND_BASE?.authScheme ?? (CHANNEL === 'development' ? 'linkcode-dev' : 'linkcode'); + /** The channel's workspace directory (`~/LinkCode`, `~/LinkCode Development`) — shared across * that channel's profiles on purpose, but never across channels (CODE-460). Must agree with the * daemon's `chatWorkspaceRoot()`, which derives the same name from its own resolved channel. */ diff --git a/apps/desktop/src/main/identity.ts b/apps/desktop/src/main/identity.ts index 725f809b..0df8d359 100644 --- a/apps/desktop/src/main/identity.ts +++ b/apps/desktop/src/main/identity.ts @@ -1,7 +1,7 @@ import { join } from 'node:path'; import { app } from 'electron'; import log from 'electron-log'; -import { APP_ID, APP_NAME } from './constants'; +import { APP_ID, APP_NAME, STORAGE_DIR_NAME } from './constants'; /** * Applies the channel × profile identity (see constants.ts) as an import side effect. Must stay @@ -14,7 +14,9 @@ app.setName(APP_NAME); // setName alone is not enough: Electron pins userData from the asar's productName (electron-builder // bakes the release "LinkCode" in even for dev-shell packages), so without this a packaged dev shell // shares the release app's settings and single-instance lock — the second one to start exits silently. -app.setPath('userData', join(app.getPath('appData'), APP_NAME)); +// STORAGE_DIR_NAME is the brand's own storage universe (equal to APP_NAME on default builds); +// resolving it here, before any other module captures a path, is what keeps brands isolated. +app.setPath('userData', join(app.getPath('appData'), STORAGE_DIR_NAME)); // Windows keys the taskbar icon, pinning, and notification identity off the AppUserModelID; without // this the taskbar shows a blank/default icon. No-op on macOS/Linux. diff --git a/apps/desktop/vite.main.config.mts b/apps/desktop/vite.main.config.mts index fe69c43f..47cbf1d0 100644 --- a/apps/desktop/vite.main.config.mts +++ b/apps/desktop/vite.main.config.mts @@ -15,6 +15,9 @@ export default defineConfig({ ...(generatedConfig && { 'import.meta.env.MAIN_VITE_CONFIG_BOOTSTRAP': JSON.stringify(generatedConfig.bootstrapJson), }), + ...(generatedConfig?.brandIdentityJson !== undefined && { + 'import.meta.env.MAIN_VITE_BRAND_IDENTITY': JSON.stringify(generatedConfig.brandIdentityJson), + }), }, envPrefix: ['MAIN_VITE_', 'VITE_'], resolve: { From 09dd504b2366201967e596960a41b91282f50c0d Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:12:13 +0000 Subject: [PATCH 04/20] feat(desktop): generate the electron-builder overlay from the rendered brand --- .gitignore | 4 + apps/desktop/scripts/package-app.mts | 42 ++++- apps/desktop/scripts/render-config-bundle.mts | 174 ++++++++++++++---- .../__tests__/electron-builder-brand.test.ts | 79 ++++++++ .../src/build/electron-builder-brand.ts | 65 +++++++ 5 files changed, 326 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/build/__tests__/electron-builder-brand.test.ts create mode 100644 apps/desktop/src/build/electron-builder-brand.ts diff --git a/.gitignore b/.gitignore index 0a64f211..ca71308d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ node_modules/ dist/ out/ build/ +# build-time source modules, not build output (the bare build/ pattern above would eat them) +!apps/desktop/src/build/ +!apps/mobile/src/build/ .vite/ expo-export/ *.tsbuildinfo @@ -32,6 +35,7 @@ apps/desktop/sidecar/ # generated immutable config bootstrap (CODE-552; rendered by the pinned config publisher) apps/desktop/generated/ +apps/mobile/generated/ apps/mobile/src/runtime/config/bundled.generated.ios.ts apps/mobile/src/runtime/config/bundled.generated.android.ts diff --git a/apps/desktop/scripts/package-app.mts b/apps/desktop/scripts/package-app.mts index 59a6b022..568ca5a5 100644 --- a/apps/desktop/scripts/package-app.mts +++ b/apps/desktop/scripts/package-app.mts @@ -159,10 +159,38 @@ function updateFeedName(arch: BuilderArch): string { return arch === 'arm64' ? 'latest-linux-arm64.yml' : 'latest-linux.yml'; } +/** Identity-owned builder fields; a passthrough `-c.` override of these on a branded build would + * silently re-brand the artifact, so they are refused outright. */ +const IDENTITY_OVERRIDE_RE = /^-c\.(?:appId|productName|protocols)\b/; + function build(): void { // Both extend the shared electron-builder.yml base; each adds its own deep-link scheme (release // `linkcode://`, dev shell `linkcode-dev://`). The base is never passed directly — it has none. - const config = devshell ? 'electron-builder.devshell.yml' : 'electron-builder.release.yml'; + // A branded build (config:render wrote generated/electron-builder.brand.json) uses the generated + // overlay instead: appId, productName, protocol scheme, and icons all come from the rendered + // brand identity, never from hand-edited YAML. + const brandConfig = join(desktopDir, 'generated', 'electron-builder.brand.json'); + const branded = existsSync(brandConfig); + if (branded && devshell) { + // out/ already embeds the branded bootstrap+identity; packing it as a dev shell would mix + // the LinkCode Development shell identity with another brand's runtime identity. + throw new Error( + 'apps/desktop/generated holds a rendered brand config; delete it (or package without ' + + '--devshell) — a dev shell must not embed another brand', + ); + } + if (branded) { + const rejected = passthrough.find((arg) => IDENTITY_OVERRIDE_RE.test(arg)); + if (rejected !== undefined) { + throw new Error(`branded builds refuse identity overrides: ${rejected}`); + } + } + const config = devshell + ? 'electron-builder.devshell.yml' + : branded + ? brandConfig + : 'electron-builder.release.yml'; + const brandIcon = join(desktopDir, 'generated', 'brand-assets', 'icon.png'); const feeds = new Map(); for (const arch of stagedArches()) { const target = materializeStaging(arch); @@ -180,15 +208,17 @@ function build(): void { '--projectDir', target, '--config', - join(desktopDir, config), + branded ? config : join(desktopDir, config), // projectDir is the staging dir, so config-relative paths would resolve under it; redirect - // output back to where CI/verify-artifacts expect it and icons to the shared repo-root assets. + // output back to where CI/verify-artifacts expect it and icons to the shared repo-root + // assets — or, on branded builds, to the staged brand assets only. `-c.directories.output=${releaseDir}`, - `-c.mac.icon=${join(assetsDir, 'linkcode.icon')}`, - `-c.win.icon=${join(assetsDir, 'icon.png')}`, + `-c.mac.icon=${branded ? brandIcon : join(assetsDir, 'linkcode.icon')}`, + `-c.win.icon=${branded ? brandIcon : join(assetsDir, 'icon.png')}`, // A directory of per-size PNGs — app-builder-lib 26+ won't expand a single PNG into a size // set, so a lone raster installs only hicolor/1024x1024 (unindexed → GNOME fallback icon). - `-c.linux.icon=${join(assetsDir, 'linux-icons')}`, + // Branded builds ship the single brand raster for now (launcher may fall back on GNOME). + `-c.linux.icon=${branded ? brandIcon : join(assetsDir, 'linux-icons')}`, ...(devshell ? ['--dir'] : []), ...passthrough, ], diff --git a/apps/desktop/scripts/render-config-bundle.mts b/apps/desktop/scripts/render-config-bundle.mts index fd534854..a1bbc346 100644 --- a/apps/desktop/scripts/render-config-bundle.mts +++ b/apps/desktop/scripts/render-config-bundle.mts @@ -1,12 +1,29 @@ // Renders the desktop build bundle through the pinned config publisher checkout. The raw bundle -// is the only generated artifact: vite.main.config.mts validates it and derives the inlined -// bootstrap from it in-process, so there is no second generated file to drift. +// is the only generated config source: vite.main.config.mts validates it and derives the inlined +// bootstrap from it in-process, so there is no second generated file to drift. With +// `--brand-artifacts` (white-label builds) it also renders the immutable brand identity, stages +// the brand's assets, and writes the electron-builder brand overlay from the same pinned source. // Run via `pnpm -F @linkcode/desktop config:render --publisher …` (no `--` separator). // Every input is an explicit pin; there is no default checkout, no fetch, and no stale fallback. -import { mkdir } from 'node:fs/promises'; -import { resolve } from 'node:path'; +// `--check` re-renders into a temp dir and fails on any byte drift against apps/desktop/generated +// instead of silently regenerating. +import { createHash } from 'node:crypto'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; -import { renderConfigBundleWithPublisher } from '@linkcode/common/node'; +import { assertBrandIdentityMatchesBundle } from '@linkcode/common/config'; +import { + renderBrandIdentityWithPublisher, + renderConfigBundleWithPublisher, + stageBrandAssets, +} from '@linkcode/common/node'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; +import { + electronBuilderBrandConfig, + serializeElectronBuilderBrandConfig, +} from '../src/build/electron-builder-brand'; const USAGE = String.raw`Usage: config:render \ --publisher config publisher checkout root \ @@ -17,18 +34,125 @@ const USAGE = String.raw`Usage: config:render \ --keyrings public keyrings JSON \ --brand --channel \ --telemetry-endpoint authenticated telemetry endpoint for this target \ - --release-manifest optional manifest digest-binding inputs and published snapshot`; + --release-manifest optional manifest digest-binding inputs and published snapshot \ + --brand-artifacts also render the brand identity, staged assets, and builder overlay \ + --check verify apps/desktop/generated is byte-identical; never rewrite`; function bail(message: string): never { console.error(`config:render: ${message}\n\n${USAGE}`); process.exit(1); } +async function renderInto( + outDir: string, + values: Record, +): Promise { + const channel = values.channel ?? bail('--channel is required'); + if (channel !== 'canary' && channel !== 'stable') { + bail('--channel must be canary or stable'); + } + // Render mode is explicit regeneration: clear the target so a previous render (e.g. a branded + // one) can never leave stale artifacts next to a fresh render of a different target. + await rm(outDir, { force: true, recursive: true }); + await mkdir(outDir, { recursive: true }); + const structuralDir = String(values.structural ?? bail('--structural is required')); + const shared = { + brandId: String(values.brand ?? bail('--brand is required')), + channel, + platform: 'desktop', + publisherDir: String(values.publisher ?? bail('--publisher is required')), + publisherGitSha: String(values['publisher-git-sha'] ?? bail('--publisher-git-sha is required')), + sourceGitSha: String(values['source-git-sha'] ?? bail('--source-git-sha is required')), + structuralDir, + } as const; + + const releaseManifest = values['release-manifest']; + const bundle = await renderConfigBundleWithPublisher({ + ...shared, + keyringsPath: String(values.keyrings ?? bail('--keyrings is required')), + outPath: resolve(outDir, 'config-build-bundle.json'), + ...(typeof releaseManifest === 'string' && { releaseManifestPath: releaseManifest }), + revisionPath: String(values.revision ?? bail('--revision is required')), + telemetryEndpoint: String( + values['telemetry-endpoint'] ?? bail('--telemetry-endpoint is required'), + ), + }); + + if (values['brand-artifacts'] === true) { + const identity = await renderBrandIdentityWithPublisher({ + ...shared, + outPath: resolve(outDir, 'brand-identity.json'), + }); + // Same target, same manifest commit — or one of the two artifacts is stale. + assertBrandIdentityMatchesBundle(identity, bundle); + + stageBrandAssets({ + assetsPath: identity.assetsPath, + outDir: resolve(outDir, 'brand-assets'), + structuralDir, + }); + + await writeFile( + resolve(outDir, 'electron-builder.brand.json'), + serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(identity)), + ); + } + + console.log( + `Rendered ${bundle.brandId}/desktop/${bundle.channel} from source ${bundle.provenance.sourceGitSha} ` + + `(revision ${bundle.provenance.configRevisionId}) into ${outDir}`, + ); +} + +function listFiles(dir: string, prefix = ''): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) appendArrayInPlace(files, listFiles(join(dir, entry.name), rel)); + else files.push(rel); + } + return files; +} + +function digest(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +/** Byte-compares a fresh render against the checked-in generated dir; drift is a hard failure so + * a stale artifact can never ride into a build unnoticed. */ +function assertNoDrift(freshDir: string, generatedDir: string): void { + if (!existsSync(generatedDir)) { + bail(`--check: ${generatedDir} does not exist; run config:render without --check first`); + } + const fresh = listFiles(freshDir); + const generated = listFiles(generatedDir); + const drifted = new Set(); + for (const file of fresh) { + if (!generated.includes(file)) drifted.add(`${file} (missing from generated)`); + else if (digest(join(freshDir, file)) !== digest(join(generatedDir, file))) { + drifted.add(`${file} (content drift)`); + } + } + for (const file of generated) { + if (!fresh.includes(file)) drifted.add(`${file} (stale extra file)`); + } + if (drifted.size > 0) { + bail( + `--check: apps/desktop/generated has drifted from the pinned source:\n ${[...drifted].join('\n ')}\n` + + 'Re-run config:render with the pinned inputs and commit nothing — generated output is never checked in', + ); + } +} + async function main(): Promise { const { values } = parseArgs({ options: { brand: { type: 'string' }, + 'brand-artifacts': { type: 'boolean' }, channel: { type: 'string' }, + check: { type: 'boolean' }, keyrings: { type: 'string' }, publisher: { type: 'string' }, 'publisher-git-sha': { type: 'string' }, @@ -41,33 +165,19 @@ async function main(): Promise { strict: true, }); - const channel = values.channel ?? bail('--channel is required'); - if (channel !== 'canary' && channel !== 'stable') { - bail('--channel must be canary or stable'); - } const generatedDir = resolve(import.meta.dirname, '../generated'); - await mkdir(generatedDir, { recursive: true }); - const outPath = resolve(generatedDir, 'config-build-bundle.json'); - - const bundle = await renderConfigBundleWithPublisher({ - brandId: values.brand ?? bail('--brand is required'), - channel, - keyringsPath: values.keyrings ?? bail('--keyrings is required'), - outPath, - platform: 'desktop', - publisherDir: values.publisher ?? bail('--publisher is required'), - publisherGitSha: values['publisher-git-sha'] ?? bail('--publisher-git-sha is required'), - releaseManifestPath: values['release-manifest'], - revisionPath: values.revision ?? bail('--revision is required'), - sourceGitSha: values['source-git-sha'] ?? bail('--source-git-sha is required'), - structuralDir: values.structural ?? bail('--structural is required'), - telemetryEndpoint: values['telemetry-endpoint'] ?? bail('--telemetry-endpoint is required'), - }); - - console.log( - `Rendered ${bundle.brandId}/desktop/${bundle.channel} from source ${bundle.provenance.sourceGitSha} ` + - `(revision ${bundle.provenance.configRevisionId}) into apps/desktop/generated`, - ); + if (values.check === true) { + const freshDir = await mkdtemp(join(tmpdir(), 'linkcode-config-check-')); + try { + await renderInto(freshDir, values); + assertNoDrift(freshDir, generatedDir); + console.log('config:render --check: generated output matches the pinned source'); + } finally { + await rm(freshDir, { force: true, recursive: true }); + } + return; + } + await renderInto(generatedDir, values); } void main(); diff --git a/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts b/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts new file mode 100644 index 00000000..1956f538 --- /dev/null +++ b/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts @@ -0,0 +1,79 @@ +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import { parseBrandIdentityArtifact } from '@linkcode/common/config'; +import { describe, expect, it } from 'vitest'; +import { + electronBuilderBrandConfig, + serializeElectronBuilderBrandConfig, +} from '../electron-builder-brand'; + +const SOURCE_SHA = '0123456789abcdef0123456789abcdef01234567'; + +function identity(overrides: Partial> = {}): BrandIdentityArtifact { + return parseBrandIdentityArtifact({ + applicationId: 'dev.arcbox.acme.desktop', + assetsPath: 'brands/acme', + brandId: 'acme', + brandIdentityVersion: 1, + channel: 'stable', + displayName: 'Acme Studio', + platform: 'desktop', + provenance: { manifestSchemaVersion: 1, sourceGitSha: SOURCE_SHA }, + storageNamespace: 'Acme Studio', + urlScheme: 'acme', + ...overrides, + }); +} + +const ZENITH_CANARY = identity({ + applicationId: 'dev.arcbox.zenith.desktop.canary', + assetsPath: 'brands/zenith', + brandId: 'zenith', + channel: 'canary', + displayName: 'Zenith Workspace Canary', + storageNamespace: 'Zenith Workspace Canary', + urlScheme: 'zenith-canary', +}); + +describe('electronBuilderBrandConfig', () => { + it('derives every identity-owned builder field from the artifact (acme stable)', () => { + expect(serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(identity()))).toBe( + `${JSON.stringify( + { + appId: 'dev.arcbox.acme.desktop', + extends: './electron-builder.yml', + linux: { executableName: 'acme', icon: 'generated/brand-assets/icon.png' }, + mac: { icon: 'generated/brand-assets/icon.png' }, + productName: 'Acme Studio', + protocols: [{ name: 'Acme Studio', schemes: ['acme'] }], + publish: null, + win: { icon: 'generated/brand-assets/icon.png' }, + }, + null, + 2, + )}\n`, + ); + }); + + it('keeps a second brand fully isolated (zenith canary)', () => { + const serialized = serializeElectronBuilderBrandConfig( + electronBuilderBrandConfig(ZENITH_CANARY), + ); + expect(serialized).toContain('"appId": "dev.arcbox.zenith.desktop.canary"'); + expect(serialized).toContain('"productName": "Zenith Workspace Canary"'); + expect(serialized).toContain('"zenith-canary"'); + // Nothing of the other brand, the default product, or internal names leaks in. + expect(serialized).not.toMatch(/acme/i); + expect(serialized.replaceAll('./electron-builder.yml', '')).not.toMatch(/linkcode/i); + }); + + it('serializes deterministically', () => { + const first = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY)); + const second = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY)); + expect(second).toBe(first); + }); + + it('refuses a non-desktop identity', () => { + const ios = identity({ applicationId: 'dev.arcbox.acme', platform: 'ios' }); + expect(() => electronBuilderBrandConfig(ios)).toThrow(/requires a desktop identity/); + }); +}); diff --git a/apps/desktop/src/build/electron-builder-brand.ts b/apps/desktop/src/build/electron-builder-brand.ts new file mode 100644 index 00000000..4e7339b3 --- /dev/null +++ b/apps/desktop/src/build/electron-builder-brand.ts @@ -0,0 +1,65 @@ +import type { BrandIdentityArtifact } from '@linkcode/common/config'; + +/** + * Derives the electron-builder overlay for one rendered brand identity. Identity-owned fields + * only — everything else (files, publish feed, targets) stays in the shared electron-builder.yml + * base this overlay extends. Never write these fields by hand for a branded build. + */ +export interface ElectronBuilderBrandConfig { + readonly appId: string; + readonly extends: string; + readonly linux: { readonly executableName: string; readonly icon: string }; + readonly mac: { readonly icon: string }; + readonly productName: string; + readonly protocols: readonly [{ readonly name: string; readonly schemes: readonly [string] }]; + /** Always null: the base config's update feed belongs to the default product. A branded + * artifact must never auto-update from another brand's feed; per-brand feeds are release + * orchestration (CODE-559), not identity. */ + readonly publish: null; + readonly win: { readonly icon: string }; +} + +/** Staged brand icon, relative to apps/desktop (the generated config lives in generated/). */ +export const BRAND_ICON_PATH = 'generated/brand-assets/icon.png'; + +export function electronBuilderBrandConfig( + identity: BrandIdentityArtifact, +): ElectronBuilderBrandConfig { + if (identity.platform !== 'desktop') { + throw new Error(`electron-builder config requires a desktop identity, got ${identity.platform}`); + } + return { + appId: identity.applicationId, + // app-builder-lib resolves `extends` against the PROJECT dir, not the config file: that is + // apps/desktop when packing in place and the staging root under package-app.mts (the deploy + // copies electron-builder.yml there) — both hold the base config at ./electron-builder.yml. + extends: './electron-builder.yml', + // The base names the executable after the default product; the brand id is the stable, + // filesystem-safe brand analog (process name and .desktop wmclass are user-visible). + linux: { executableName: identity.brandId, icon: BRAND_ICON_PATH }, + mac: { icon: BRAND_ICON_PATH }, + productName: identity.displayName, + protocols: [{ name: identity.displayName, schemes: [identity.urlScheme] }], + publish: null, + win: { icon: BRAND_ICON_PATH }, + }; +} + +/** Deterministic bytes: two-space indent, lexicographically sorted keys, trailing newline — + * the same serialization contract as the publisher's identity artifact. */ +export function serializeElectronBuilderBrandConfig(config: ElectronBuilderBrandConfig): string { + return `${JSON.stringify(sortedJson(config), null, 2)}\n`; +} + +function sortedJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map((entry) => sortedJson(entry)); + if (typeof value === 'object' && value !== null) { + const record = value as Record; + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, sortedJson(record[key])]), + ); + } + return value; +} From 97acb9c2e5dd2f49d094eabe629c86631344f794 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 3 Aug 2026 20:12:31 +0000 Subject: [PATCH 05/20] feat(mobile): apply the rendered brand to Expo config before prebuild --- apps/mobile/app.config.ts | 62 +++++ apps/mobile/scripts/render-config-bundle.mts | 215 ++++++++++++++---- .../src/build/__tests__/expo-brand.test.ts | 180 +++++++++++++++ apps/mobile/src/build/expo-brand.ts | 213 +++++++++++++++++ apps/mobile/tsconfig.json | 3 +- 5 files changed, 627 insertions(+), 46 deletions(-) create mode 100644 apps/mobile/app.config.ts create mode 100644 apps/mobile/src/build/__tests__/expo-brand.test.ts create mode 100644 apps/mobile/src/build/expo-brand.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 00000000..ceecf0c3 --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,62 @@ +// Dynamic Expo config (CODE-558): app.json stays the default-product base; when +// scripts/render-config-bundle.mts has rendered a brand into generated/, every identity-owned +// field is replaced from that immutable overlay before prebuild — there is no runtime mutation +// of native identity and no partial application: an incomplete or inconsistent generated set +// aborts config evaluation instead of falling back to the default brand. +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import type { ConfigContext, ExpoConfig } from 'expo/config'; +// The explicit .ts extension is load-bearing: Expo's config evaluator transpiles only this +// entry file, so the import must resolve through Node's own require — which loads .ts (type +// stripping, Node >= 24) only when the extension is spelled out. +import { + applyBrandExpoConfig, + deriveExpoBrandOverlay, + parseExpoBrandOverlay, + serializeExpoBrandOverlay, +} from './src/build/expo-brand.ts'; + +const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +function loadGeneratedBrand(): ReturnType | null { + const generatedDir = join(__dirname, 'generated'); + const overlayPath = join(generatedDir, 'expo-brand.json'); + const iosPath = join(generatedDir, 'brand-identity.ios.json'); + const androidPath = join(generatedDir, 'brand-identity.android.json'); + const iconPath = join(generatedDir, 'brand-assets', 'icon.png'); + const present = [overlayPath, iosPath, androidPath, iconPath].filter((path) => existsSync(path)); + if (present.length === 0) return null; + if (present.length !== 4) { + throw new Error( + 'apps/mobile/generated is incomplete — re-run `pnpm -F @linkcode/mobile config:render`', + ); + } + const overlay = parseExpoBrandOverlay(JSON.parse(readFileSync(overlayPath, 'utf8'))); + // The overlay must still be the one derived from the identity artifacts next to it; a + // hand-edited overlay (or a stale one after re-rendering another brand) fails here. Deep + // field validation ran at render time (the renderer only writes publisher-validated + // artifacts); Expo CLI's config evaluator cannot load @linkcode/common at runtime, so this + // re-check is structural: platform split, cross-platform consistency, and overlay equality. + const rederived = deriveExpoBrandOverlay( + JSON.parse(readFileSync(iosPath, 'utf8')) as BrandIdentityArtifact, + JSON.parse(readFileSync(androidPath, 'utf8')) as BrandIdentityArtifact, + ); + if (serializeExpoBrandOverlay(rederived) !== serializeExpoBrandOverlay(overlay)) { + throw new Error( + 'apps/mobile/generated/expo-brand.json does not match the rendered identity artifacts — ' + + 're-run `pnpm -F @linkcode/mobile config:render`', + ); + } + const icon = readFileSync(iconPath); + if (icon.length < PNG_MAGIC.length || !icon.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) { + throw new Error('apps/mobile/generated/brand-assets/icon.png is not a PNG'); + } + return overlay; +} + +export default ({ config }: ConfigContext): ExpoConfig => { + const base = config as ExpoConfig; + const overlay = loadGeneratedBrand(); + return overlay === null ? base : applyBrandExpoConfig(base, overlay); +}; diff --git a/apps/mobile/scripts/render-config-bundle.mts b/apps/mobile/scripts/render-config-bundle.mts index 68322f56..c203128f 100644 --- a/apps/mobile/scripts/render-config-bundle.mts +++ b/apps/mobile/scripts/render-config-bundle.mts @@ -1,12 +1,25 @@ -// Renders the iOS and Android build bundles through the pinned config publisher checkout into -// bundled.generated..ts, which Metro resolves over the committed { bundle: null } -// sentinel. Run via `pnpm -F @linkcode/mobile config:render --publisher …` (no `--` separator). -// Every input is an explicit pin; there is no default checkout, no fetch, and no stale fallback. -import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +// Renders the iOS and Android build bundles AND brand identities through the pinned config +// publisher checkout: the bundles land in bundled.generated..ts (which Metro resolves +// over the committed { bundle: null } sentinel) and the identity artifacts, brand assets, and +// Expo overlay land in apps/mobile/generated/ for app.config.ts. Run via +// `pnpm -F @linkcode/mobile config:render --publisher …` (no `--` separator). Every input is an +// explicit pin; there is no default checkout, no fetch, and no stale fallback. `--check` +// re-renders into a temp dir and fails on any byte drift instead of silently regenerating. +import { createHash } from 'node:crypto'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; -import { renderConfigBundleWithPublisher } from '@linkcode/common/node'; +import { assertBrandIdentityMatchesBundle } from '@linkcode/common/config'; +import { + renderBrandIdentityWithPublisher, + renderConfigBundleWithPublisher, + stageBrandAssets, +} from '@linkcode/common/node'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; +import { trueFn } from 'foxts/noop'; +import { deriveExpoBrandOverlay, serializeExpoBrandOverlay } from '../src/build/expo-brand'; const USAGE = String.raw`Usage: config:render \ --publisher config publisher checkout root \ @@ -18,33 +31,28 @@ const USAGE = String.raw`Usage: config:render \ --brand --channel \ --telemetry-endpoint authenticated telemetry endpoint for this target \ --release-manifest-ios optional per-target manifest digest-binding inputs/snapshot \ - --release-manifest-android required together with --release-manifest-ios`; + --release-manifest-android required together with --release-manifest-ios \ + --check verify all generated outputs are byte-identical; never rewrite`; const MOBILE_PLATFORMS = ['ios', 'android'] as const; +type MobilePlatform = (typeof MOBILE_PLATFORMS)[number]; function bail(message: string): never { console.error(`config:render: ${message}\n\n${USAGE}`); process.exit(1); } -async function main(): Promise { - const { values } = parseArgs({ - options: { - brand: { type: 'string' }, - channel: { type: 'string' }, - keyrings: { type: 'string' }, - publisher: { type: 'string' }, - 'publisher-git-sha': { type: 'string' }, - 'release-manifest-android': { type: 'string' }, - 'release-manifest-ios': { type: 'string' }, - revision: { type: 'string' }, - 'source-git-sha': { type: 'string' }, - structural: { type: 'string' }, - 'telemetry-endpoint': { type: 'string' }, - }, - strict: true, - }); +interface RenderRoots { + /** Receives bundled.generated..ts (real: src/runtime/config). */ + readonly configDir: string; + /** Receives brand-identity.*.json, brand-assets/, expo-brand.json (real: generated/). */ + readonly generatedDir: string; +} +async function renderInto( + roots: RenderRoots, + values: Record, +): Promise { const channel = values.channel ?? bail('--channel is required'); if (channel !== 'canary' && channel !== 'stable') { bail('--channel must be canary or stable'); @@ -56,35 +64,56 @@ async function main(): Promise { if ((releaseManifests.ios === undefined) !== (releaseManifests.android === undefined)) { bail('--release-manifest-ios and --release-manifest-android must be passed together'); } - const configDir = resolve(import.meta.dirname, '../src/runtime/config'); - const workDir = await mkdtemp(join(tmpdir(), 'linkcode-config-render-')); + const structuralDir = String(values.structural ?? bail('--structural is required')); + const shared = { + brandId: String(values.brand ?? bail('--brand is required')), + channel, + publisherDir: String(values.publisher ?? bail('--publisher is required')), + publisherGitSha: String(values['publisher-git-sha'] ?? bail('--publisher-git-sha is required')), + sourceGitSha: String(values['source-git-sha'] ?? bail('--source-git-sha is required')), + structuralDir, + } as const; + await mkdir(roots.configDir, { recursive: true }); + await mkdir(roots.generatedDir, { recursive: true }); + const workDir = await mkdtemp(join(tmpdir(), 'linkcode-config-render-')); + const identities = {} as Record< + MobilePlatform, + Awaited> + >; try { // Both platforms render in one invocation so they can never drift apart in source or inputs. for (const platform of MOBILE_PLATFORMS) { const outPath = join(workDir, `${platform}.json`); + const releaseManifest = releaseManifests[platform]; // eslint-disable-next-line no-await-in-loop -- renders share pinned checkouts sequentially const bundle = await renderConfigBundleWithPublisher({ - brandId: values.brand ?? bail('--brand is required'), - channel, - keyringsPath: values.keyrings ?? bail('--keyrings is required'), + ...shared, + keyringsPath: String(values.keyrings ?? bail('--keyrings is required')), outPath, platform, - publisherDir: values.publisher ?? bail('--publisher is required'), - publisherGitSha: values['publisher-git-sha'] ?? bail('--publisher-git-sha is required'), - releaseManifestPath: releaseManifests[platform], - revisionPath: values.revision ?? bail('--revision is required'), - sourceGitSha: values['source-git-sha'] ?? bail('--source-git-sha is required'), - structuralDir: values.structural ?? bail('--structural is required'), - telemetryEndpoint: values['telemetry-endpoint'] ?? bail('--telemetry-endpoint is required'), + ...(typeof releaseManifest === 'string' && { releaseManifestPath: releaseManifest }), + revisionPath: String(values.revision ?? bail('--revision is required')), + telemetryEndpoint: String( + values['telemetry-endpoint'] ?? bail('--telemetry-endpoint is required'), + ), + }); + // eslint-disable-next-line no-await-in-loop -- one identity render per platform + const identity = await renderBrandIdentityWithPublisher({ + ...shared, + outPath: resolve(roots.generatedDir, `brand-identity.${platform}.json`), + platform, }); + // Same target, same manifest commit — or one of the two artifacts is stale. + assertBrandIdentityMatchesBundle(identity, bundle); + identities[platform] = identity; // eslint-disable-next-line no-await-in-loop -- read back the file the render just wrote const rendered = await readFile(outPath, 'utf8'); // The generated module shape is load-bearing: smoke-native-entry-export.cjs parses the // object literal back out of it to verify the compiled Hermes export. // eslint-disable-next-line no-await-in-loop -- one small write per platform await writeFile( - join(workDir, `bundled.generated.${platform}.ts`), + join(roots.configDir, `bundled.generated.${platform}.ts`), '// Generated by scripts/render-config-bundle.mts — do not edit, do not commit.\n' + `const generatedConfigModule: unknown = { bundle: ${rendered.trim()} };\n` + 'export default generatedConfigModule;\n', @@ -94,17 +123,113 @@ async function main(): Promise { `${bundle.provenance.sourceGitSha} (revision ${bundle.provenance.configRevisionId})`, ); } - await Promise.all( - MOBILE_PLATFORMS.map((platform) => - copyFile( - join(workDir, `bundled.generated.${platform}.ts`), - join(configDir, `bundled.generated.${platform}.ts`), - ), - ), - ); } finally { await rm(workDir, { force: true, recursive: true }); } + + const overlay = deriveExpoBrandOverlay(identities.ios, identities.android); + stageBrandAssets({ + assetsPath: identities.ios.assetsPath, + outDir: resolve(roots.generatedDir, 'brand-assets'), + structuralDir, + }); + await writeFile( + resolve(roots.generatedDir, 'expo-brand.json'), + serializeExpoBrandOverlay(overlay), + ); + console.log(`Wrote Expo brand overlay for ${overlay.brandId}/${overlay.channel}`); +} + +function listFiles(dir: string, prefix = ''): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) appendArrayInPlace(files, listFiles(join(dir, entry.name), rel)); + else files.push(rel); + } + return files; +} + +function digest(path: string): string { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +/** Byte-compares a fresh render against the on-disk outputs; drift is a hard failure so a stale + * artifact can never ride into a prebuild unnoticed. `relevant` scopes the comparison to the + * files this renderer owns — src/runtime/config also holds committed, non-generated modules. */ +function assertNoDrift( + freshDir: string, + realDir: string, + label: string, + relevant: (file: string) => boolean, +): void { + if (!existsSync(realDir)) { + bail(`--check: ${realDir} does not exist; run config:render without --check first`); + } + const fresh = listFiles(freshDir).filter(relevant); + const real = listFiles(realDir).filter(relevant); + const drifted = new Set(); + for (const file of fresh) { + if (!real.includes(file)) drifted.add(`${file} (missing from ${label})`); + else if (digest(join(freshDir, file)) !== digest(join(realDir, file))) { + drifted.add(`${file} (content drift)`); + } + } + for (const file of real) { + if (!fresh.includes(file)) drifted.add(`${file} (stale extra file)`); + } + if (drifted.size > 0) { + bail( + `--check: ${label} has drifted from the pinned source:\n ${[...drifted].join('\n ')}\n` + + 'Re-run config:render with the pinned inputs and commit nothing — generated output is never checked in', + ); + } +} + +async function main(): Promise { + const { values } = parseArgs({ + options: { + brand: { type: 'string' }, + channel: { type: 'string' }, + check: { type: 'boolean' }, + keyrings: { type: 'string' }, + publisher: { type: 'string' }, + 'publisher-git-sha': { type: 'string' }, + 'release-manifest-android': { type: 'string' }, + 'release-manifest-ios': { type: 'string' }, + revision: { type: 'string' }, + 'source-git-sha': { type: 'string' }, + structural: { type: 'string' }, + 'telemetry-endpoint': { type: 'string' }, + }, + strict: true, + }); + + const roots: RenderRoots = { + configDir: resolve(import.meta.dirname, '../src/runtime/config'), + generatedDir: resolve(import.meta.dirname, '../generated'), + }; + if (values.check === true) { + const freshRoot = await mkdtemp(join(tmpdir(), 'linkcode-config-check-')); + const fresh: RenderRoots = { + configDir: join(freshRoot, 'runtime-config'), + generatedDir: join(freshRoot, 'generated'), + }; + try { + await renderInto(fresh, values); + assertNoDrift(fresh.configDir, roots.configDir, 'src/runtime/config', (file) => + MOBILE_PLATFORMS.some((p) => file === `bundled.generated.${p}.ts`), + ); + assertNoDrift(fresh.generatedDir, roots.generatedDir, 'generated', trueFn); + console.log('config:render --check: generated output matches the pinned source'); + } finally { + await rm(freshRoot, { force: true, recursive: true }); + } + return; + } + await renderInto(roots, values); } void main(); diff --git a/apps/mobile/src/build/__tests__/expo-brand.test.ts b/apps/mobile/src/build/__tests__/expo-brand.test.ts new file mode 100644 index 00000000..c8c45dbb --- /dev/null +++ b/apps/mobile/src/build/__tests__/expo-brand.test.ts @@ -0,0 +1,180 @@ +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import { describe, expect, it } from 'vitest'; +import baseAppJson from '../../../app.json'; +import type { ExpoBrandableConfig } from '../expo-brand'; +import { + applyBrandExpoConfig, + deriveExpoBrandOverlay, + parseExpoBrandOverlay, + serializeExpoBrandOverlay, +} from '../expo-brand'; + +const SOURCE_SHA = '0123456789abcdef0123456789abcdef01234567'; + +function identity( + platform: 'ios' | 'android', + overrides: Partial = {}, +): BrandIdentityArtifact { + return { + applicationId: 'dev.arcbox.acme', + assetsPath: 'brands/acme', + brandId: 'acme', + brandIdentityVersion: 1, + channel: 'stable', + displayName: 'Acme Studio', + platform, + provenance: { manifestSchemaVersion: 1, sourceGitSha: SOURCE_SHA }, + storageNamespace: 'Acme Studio', + urlScheme: 'acme', + ...overrides, + }; +} + +const ACME = deriveExpoBrandOverlay(identity('ios'), identity('android')); + +const ZENITH = deriveExpoBrandOverlay( + identity('ios', { + applicationId: 'dev.arcbox.zenith.canary', + assetsPath: 'brands/zenith', + brandId: 'zenith', + channel: 'canary', + displayName: 'Zenith Workspace Canary', + storageNamespace: 'Zenith Workspace Canary', + urlScheme: 'zenith-canary', + }), + identity('android', { + applicationId: 'dev.arcbox.zenith.canary', + assetsPath: 'brands/zenith', + brandId: 'zenith', + channel: 'canary', + displayName: 'Zenith Workspace Canary', + storageNamespace: 'Zenith Workspace Canary', + urlScheme: 'zenith-canary', + }), +); + +const BASE = baseAppJson.expo as unknown as ExpoBrandableConfig; + +describe('deriveExpoBrandOverlay', () => { + it('collapses matching platform identities into one overlay', () => { + expect(ACME).toStrictEqual({ + androidPackage: 'dev.arcbox.acme', + brandId: 'acme', + channel: 'stable', + displayName: 'Acme Studio', + iosBundleIdentifier: 'dev.arcbox.acme', + sourceGitSha: SOURCE_SHA, + urlScheme: 'acme', + }); + }); + + it('fails closed on swapped platforms', () => { + expect(() => deriveExpoBrandOverlay(identity('android'), identity('android'))).toThrow( + /expected an ios identity/, + ); + expect(() => deriveExpoBrandOverlay(identity('ios'), identity('ios'))).toThrow( + /expected an android identity/, + ); + }); + + it('fails closed when the two artifacts disagree', () => { + expect(() => + deriveExpoBrandOverlay(identity('ios'), identity('android', { displayName: 'Other' })), + ).toThrow(/displayName differs/); + expect(() => + deriveExpoBrandOverlay(identity('ios'), identity('android', { brandId: 'zenith' })), + ).toThrow(/brandId differs/); + expect(() => + deriveExpoBrandOverlay( + identity('ios'), + identity('android', { + provenance: { + manifestSchemaVersion: 1, + sourceGitSha: 'feedfacefeedfacefeedfacefeedfacefeedface', + }, + }), + ), + ).toThrow(/sourceGitSha differs/); + }); +}); + +describe('overlay serialization', () => { + it('is deterministic and round-trips through the structural parser', () => { + const first = serializeExpoBrandOverlay(ACME); + expect(serializeExpoBrandOverlay(ACME)).toBe(first); + expect(parseExpoBrandOverlay(JSON.parse(first))).toStrictEqual(ACME); + }); + + it('rejects missing, extra, and empty fields', () => { + const valid = JSON.parse(serializeExpoBrandOverlay(ACME)) as Record; + const { urlScheme: _dropped, ...missing } = valid; + expect(() => parseExpoBrandOverlay(missing)).toThrow(/exactly/); + expect(() => parseExpoBrandOverlay({ ...valid, extraField: 'x' })).toThrow(/exactly/); + expect(() => parseExpoBrandOverlay({ ...valid, displayName: '' })).toThrow(/non-empty/); + expect(() => parseExpoBrandOverlay(null)).toThrow(/object/); + }); +}); + +describe('applyBrandExpoConfig', () => { + const branded = applyBrandExpoConfig(BASE, ACME); + + it('replaces every identity-owned field from the overlay', () => { + expect(branded.name).toBe('Acme Studio'); + expect(branded.slug).toBe('acme'); + expect(branded.scheme).toBe('acme'); + expect(branded.icon).toBe('./generated/brand-assets/icon.png'); + expect(branded.ios?.bundleIdentifier).toBe('dev.arcbox.acme'); + expect(branded.ios?.icon).toBe('./generated/brand-assets/icon.png'); + expect(branded.android?.package).toBe('dev.arcbox.acme'); + expect(branded.android?.adaptiveIcon).toStrictEqual({ + backgroundColor: '#FFFFFF', + foregroundImage: './generated/brand-assets/icon.png', + }); + expect(branded.splash?.image).toBe('./generated/brand-assets/icon.png'); + expect(branded.web?.favicon).toBe('./generated/brand-assets/icon.png'); + }); + + it('strips the default product update/EAS wiring instead of inheriting it', () => { + expect(branded.updates).toBeUndefined(); + expect(branded.extra).toStrictEqual({}); + expect(JSON.stringify(branded)).not.toContain('u.expo.dev'); + }); + + it('rebrands user-visible permission prompts and the share app group', () => { + const text = JSON.stringify(branded); + expect(text).toContain('"appGroupId":"group.dev.arcbox.acme"'); + expect(text).toContain('Acme Studio needs camera access'); + expect(text).toContain('Acme Studio needs microphone access'); + expect(text).toContain('Acme Studio uses Face ID'); + }); + + it('leaks nothing of the default product identity', () => { + const text = JSON.stringify(branded); + expect(text).not.toContain('LinkCode'); + expect(text).not.toContain('com.arcboxlabs.linkcode'); + // The daemon discovery service type is shared-core runtime behavior, not brand identity. + expect(text).toContain('_linkcode._tcp'); + }); + + it('keeps non-identity configuration untouched', () => { + expect(branded.orientation).toBe(BASE.orientation); + expect(branded.runtimeVersion).toStrictEqual(BASE.runtimeVersion); + expect(branded.android?.permissions).toStrictEqual(BASE.android?.permissions); + expect(branded.ios?.appleTeamId).toBe(BASE.ios?.appleTeamId); + expect(branded.plugins?.length).toBe(BASE.plugins?.length); + }); + + it('keeps a second brand fully isolated (zenith canary)', () => { + const zenith = applyBrandExpoConfig(BASE, ZENITH); + const text = JSON.stringify(zenith); + expect(zenith.ios?.bundleIdentifier).toBe('dev.arcbox.zenith.canary'); + expect(zenith.android?.package).toBe('dev.arcbox.zenith.canary'); + expect(zenith.scheme).toBe('zenith-canary'); + expect(text).not.toContain('LinkCode'); + expect(text).not.toMatch(/acme/i); + }); + + it('is deterministic', () => { + expect(applyBrandExpoConfig(BASE, ACME)).toStrictEqual(branded); + }); +}); diff --git a/apps/mobile/src/build/expo-brand.ts b/apps/mobile/src/build/expo-brand.ts new file mode 100644 index 00000000..df192d0c --- /dev/null +++ b/apps/mobile/src/build/expo-brand.ts @@ -0,0 +1,213 @@ +// Build-time only (config:render + app.config.ts): derives the Expo brand overlay from the two +// rendered mobile identity artifacts and applies it to the static app.json base. Never imported +// by runtime code — native identity is fixed at prebuild and must not be re-derived on device. +import type { BrandIdentityArtifact } from '@linkcode/common/config'; +import type { ExpoConfig } from 'expo/config'; + +/** One overlay for both mobile platforms: the shared fields must agree across the ios and + * android artifacts (same manifest render), while each platform keeps its own application id. */ +export interface ExpoBrandOverlay { + readonly androidPackage: string; + readonly brandId: string; + readonly channel: string; + readonly displayName: string; + readonly iosBundleIdentifier: string; + readonly sourceGitSha: string; + readonly urlScheme: string; +} + +/** Staged brand icon, relative to apps/mobile (where app.config.ts resolves asset paths). */ +export const MOBILE_BRAND_ICON_PATH = './generated/brand-assets/icon.png'; + +function fail(message: string): never { + throw new Error(message); +} + +function requireEqual(field: string, ios: unknown, android: unknown): void { + if (ios !== android) { + fail( + `brand identity ${field} differs between ios (${String(ios)}) and android ` + + `(${String(android)}); both artifacts must come from one manifest render`, + ); + } +} + +/** Collapses the two platform identities into one overlay, failing closed on any disagreement — + * mixed-render artifacts must never produce a build that is one brand on iOS and another on + * Android. */ +export function deriveExpoBrandOverlay( + ios: BrandIdentityArtifact, + android: BrandIdentityArtifact, +): ExpoBrandOverlay { + if (ios.platform !== 'ios') fail(`expected an ios identity, got ${ios.platform}`); + if (android.platform !== 'android') { + fail(`expected an android identity, got ${android.platform}`); + } + requireEqual('brandId', ios.brandId, android.brandId); + requireEqual('channel', ios.channel, android.channel); + requireEqual('displayName', ios.displayName, android.displayName); + requireEqual('urlScheme', ios.urlScheme, android.urlScheme); + requireEqual('assetsPath', ios.assetsPath, android.assetsPath); + requireEqual( + 'provenance.sourceGitSha', + ios.provenance.sourceGitSha, + android.provenance.sourceGitSha, + ); + return { + androidPackage: android.applicationId, + brandId: ios.brandId, + channel: ios.channel, + displayName: ios.displayName, + iosBundleIdentifier: ios.applicationId, + sourceGitSha: ios.provenance.sourceGitSha, + urlScheme: ios.urlScheme, + }; +} + +/** Deterministic bytes: two-space indent, lexicographically sorted keys, trailing newline — + * the same serialization contract as the publisher's identity artifact. */ +export function serializeExpoBrandOverlay(overlay: ExpoBrandOverlay): string { + return `${JSON.stringify( + Object.fromEntries( + Object.entries(overlay).sort(([a], [b]) => (a < b ? -1 : 1)), + ), + null, + 2, + )}\n`; +} + +const OVERLAY_KEYS = [ + 'androidPackage', + 'brandId', + 'channel', + 'displayName', + 'iosBundleIdentifier', + 'sourceGitSha', + 'urlScheme', +] as const; + +/** Structural check for the overlay JSON read back by app.config.ts. Deep identity validation + * already happened at render time; this rejects truncated or hand-edited files. */ +export function parseExpoBrandOverlay(value: unknown): ExpoBrandOverlay { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail('expo brand overlay must be a JSON object'); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== OVERLAY_KEYS.length || keys.some((key, i) => key !== OVERLAY_KEYS[i])) { + fail(`expo brand overlay must contain exactly: ${OVERLAY_KEYS.join(', ')}`); + } + for (const key of OVERLAY_KEYS) { + if (typeof record[key] !== 'string' || record[key] === '') { + fail(`expo brand overlay field ${key} must be a non-empty string`); + } + } + return record as unknown as ExpoBrandOverlay; +} + +/** The default product name as it appears in user-facing template strings of the base config + * (permission prompts). Only exact-case occurrences are rebranded; lowercase protocol/service + * identifiers (`_linkcode._tcp`) are shared-core runtime contracts and stay untouched. */ +const DEFAULT_PRODUCT_NAME = /LinkCode/g; + +function rebrandStrings(value: unknown, displayName: string): unknown { + if (typeof value === 'string') return value.replace(DEFAULT_PRODUCT_NAME, displayName); + if (Array.isArray(value)) return value.map((entry) => rebrandStrings(entry, displayName)); + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + rebrandStrings(entry, displayName), + ]), + ); + } + return value; +} + +/** SDK 57's ExpoConfig type dropped the root `splash` key, but app.json still carries it and + * prebuild still honors it — keep it typed so the brand image replacement is checked. */ +export type ExpoBrandableConfig = ExpoConfig & { + readonly splash?: Record; +}; + +type PluginEntry = NonNullable[number]; + +/** Permission-prompt strings owned by these plugins are user-visible and carry the product + * name; everything else in the plugin list (sentry project, gradle tweaks) is not identity. */ +const REBRANDED_PLUGINS = new Set(['expo-audio', 'expo-local-authentication']); + +function rebrandPlugin( + entry: PluginEntry, + overlay: ExpoBrandOverlay, +): PluginEntry { + if (!Array.isArray(entry)) return entry; + const [name, props] = entry; + if (name === 'expo-sharing' && typeof props === 'object' && props !== null) { + const shared = props as Record; + const ios = shared.ios; + return [ + name, + { + ...shared, + ...(typeof ios === 'object' && ios !== null + ? { + ios: { + ...(ios as Record), + // The app group namespaces the share-extension container; it must follow the + // brand's bundle id or two brands would share (and fight over) one container. + appGroupId: `group.${overlay.iosBundleIdentifier}`, + }, + } + : {}), + }, + ] as PluginEntry; + } + if (typeof name === 'string' && REBRANDED_PLUGINS.has(name)) { + return [name, rebrandStrings(props, overlay.displayName)] as PluginEntry; + } + return entry; +} + +/** + * Applies one rendered brand overlay to the static base config. Everything identity-owned is + * replaced wholesale — name, slug, scheme, application ids, icons/splash, permission prompts, + * share app group — and the default product's update/EAS wiring is stripped: a branded build + * must never publish to or update from the default project's channels (per-brand release + * wiring is CODE-559, not identity). + */ +export function applyBrandExpoConfig( + config: ExpoBrandableConfig, + overlay: ExpoBrandOverlay, + iconPath: string = MOBILE_BRAND_ICON_PATH, +): ExpoBrandableConfig { + const { updates: _updates, description: _description, ...base } = config; + const { eas: _eas, ...extra } = base.extra ?? {}; + return { + ...base, + android: { + ...base.android, + adaptiveIcon: { + backgroundColor: base.android?.adaptiveIcon?.backgroundColor ?? '#FFFFFF', + foregroundImage: iconPath, + }, + package: overlay.androidPackage, + }, + extra, + icon: iconPath, + ios: { + ...base.ios, + bundleIdentifier: overlay.iosBundleIdentifier, + icon: iconPath, + infoPlist: rebrandStrings(base.ios?.infoPlist, overlay.displayName) as Record< + string, + unknown + >, + }, + name: overlay.displayName, + plugins: base.plugins?.map((entry) => rebrandPlugin(entry, overlay)), + scheme: overlay.urlScheme, + slug: overlay.brandId, + splash: { ...base.splash, image: iconPath }, + web: { ...base.web, favicon: iconPath }, + }; +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index c174d273..fd5e0c7d 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -6,7 +6,8 @@ "paths": { "@mobile/*": ["./src/*"] }, + "allowImportingTsExtensions": true, "strict": true }, - "include": ["src", "modules", "scripts", "vitest.config.ts"] + "include": ["src", "modules", "scripts", "app.config.ts", "vitest.config.ts"] } From cce8705674db07320faf9fa629039514df5554bf Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 07:23:09 +0000 Subject: [PATCH 06/20] fix(mobile): render startup mark from brand assets Amp-Thread-ID: https://ampcode.com/threads/T-019fcb86-b39a-718c-8565-6dd3a5941d91 --- apps/mobile/metro.config.js | 32 +++++++++++++++++-- .../src/components/shell/brand-mark-icon.ts | 1 + .../src/components/shell/brand-mark.tsx | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/components/shell/brand-mark-icon.ts diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index cea21198..20d789d3 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -1,8 +1,9 @@ // Expo monorepo Metro config + Uniwind + Sentry. // getSentryExpoConfig wraps Expo's default config with Sentry's source-map serializer. +const { existsSync } = require('node:fs'); +const path = require('node:path'); const { getSentryExpoConfig } = require('@sentry/react-native/metro'); const { withUniwindConfig } = require('uniwind/metro'); -const path = require('node:path'); const projectRoot = __dirname; const workspaceRoot = path.resolve(projectRoot, '../..'); @@ -18,11 +19,36 @@ config.resolver.nodeModulesPaths = [ path.resolve(workspaceRoot, 'node_modules'), ]; -// 3) Bundle the terminal's self-hosted font in native and web exports. +// 3) Native identity and the in-app mark must select one complete generated asset set or none. +const brandMarkModule = '@mobile/components/shell/brand-mark-icon'; +const generatedBrandIcon = path.resolve(projectRoot, 'generated/brand-assets/icon.png'); +const generatedBrandFiles = [ + path.resolve(projectRoot, 'generated/expo-brand.json'), + path.resolve(projectRoot, 'generated/brand-identity.ios.json'), + path.resolve(projectRoot, 'generated/brand-identity.android.json'), + generatedBrandIcon, +]; +config.resolver.resolveRequest = (context, moduleName, platform) => { + let request = moduleName; + if (moduleName === brandMarkModule) { + const present = generatedBrandFiles.filter((file) => existsSync(file)); + if (present.length !== 0 && present.length !== generatedBrandFiles.length) { + throw new Error( + 'apps/mobile/generated is incomplete — re-run `pnpm -F @linkcode/mobile config:render`', + ); + } + if (present.length === generatedBrandFiles.length) { + request = generatedBrandIcon; + } + } + return context.resolveRequest(context, request, platform); +}; + +// 4) Bundle the terminal's self-hosted font in native and web exports. // expo-sqlite's web worker imports WASM, while the DOM terminal bundles local WOFF2 fonts. config.resolver.assetExts.push('wasm', 'woff2'); -// 4) Apply Uniwind, compiling ./src/global.css and generating className typings. +// 5) Apply Uniwind, compiling ./src/global.css and generating className typings. module.exports = withUniwindConfig(config, { cssEntryFile: './src/global.css', dtsFile: './src/uniwind-types.d.ts', diff --git a/apps/mobile/src/components/shell/brand-mark-icon.ts b/apps/mobile/src/components/shell/brand-mark-icon.ts new file mode 100644 index 00000000..76da0963 --- /dev/null +++ b/apps/mobile/src/components/shell/brand-mark-icon.ts @@ -0,0 +1 @@ +export { default } from '../../../assets/splash-icon.png'; diff --git a/apps/mobile/src/components/shell/brand-mark.tsx b/apps/mobile/src/components/shell/brand-mark.tsx index 98a35d49..a10c140d 100644 --- a/apps/mobile/src/components/shell/brand-mark.tsx +++ b/apps/mobile/src/components/shell/brand-mark.tsx @@ -1,5 +1,5 @@ +import icon from '@mobile/components/shell/brand-mark-icon'; import { Image, StyleSheet, View } from 'react-native'; -import icon from '../../../assets/splash-icon.png'; /** * The app icon as an in-app brand mark: the transparent splash glyph on a From 77f3fb89d4268ffa93a9cc70d5281b5496b7318e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Tue, 4 Aug 2026 07:51:36 +0000 Subject: [PATCH 07/20] fix(desktop): bind packaged identity to rendered brand Amp-Thread-ID: https://ampcode.com/threads/T-019fcb86-b39a-718c-8565-6dd3a5941d91 --- apps/desktop/scripts/config-bundle.mts | 132 ++++++++++++++---- apps/desktop/scripts/package-app.mts | 10 +- apps/desktop/scripts/package-config.mts | 75 ++++++++++ .../src/__tests__/config-bundle.test.ts | 77 +++++++++- apps/desktop/turbo.json | 3 +- 5 files changed, 260 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/scripts/package-config.mts diff --git a/apps/desktop/scripts/config-bundle.mts b/apps/desktop/scripts/config-bundle.mts index e8fe3c42..4bdf5488 100644 --- a/apps/desktop/scripts/config-bundle.mts +++ b/apps/desktop/scripts/config-bundle.mts @@ -3,8 +3,9 @@ // validates it with the frozen v1 loader, derives the inlined bootstrap from the validated object // in-process, and stages the exact bytes it parsed. Any ambient MAIN_VITE_CONFIG_BOOTSTRAP is a // hard error — generated output cannot be overridden. -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; import { isObjectEmpty } from 'foxts/is-object-empty'; // Relative on purpose: this module is inlined into the bundled Vite config, which runs under // plain Node — Node cannot resolve the package's extensionless TS source exports. @@ -13,12 +14,13 @@ import { configBuildBundleDefaults, parseConfigBuildBundle, } from '../../../packages/foundation/common/src/config/build-bundle'; // eslint-disable-line import-x/no-relative-packages -- Vite must inline this source dependency. +import { + electronBuilderBrandConfig, + serializeElectronBuilderBrandConfig, +} from '../src/build/electron-builder-brand'; -export interface GeneratedConfigBundle { +interface GeneratedConfigBundleBase { readonly bootstrapJson: string; - /** Present only on white-label renders (config:render --brand-artifacts); the default product - * never has one and keeps its built-in identity. */ - readonly brandIdentityJson?: string; readonly bundleText: string; } @@ -27,26 +29,73 @@ const CONFORMANCE_FIXTURE_PUBLIC_KEYS = new Set([ 'PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw', '_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU', ]); +interface DefaultGeneratedConfigBundle extends GeneratedConfigBundleBase { + readonly brandBuilderConfigText?: undefined; + readonly brandIconBytes?: undefined; + readonly brandIdentityJson?: undefined; +} + +interface BrandedGeneratedConfigBundle extends GeneratedConfigBundleBase { + readonly brandBuilderConfigText: string; + readonly brandIconBytes: Uint8Array; + readonly brandIdentityJson: string; +} + +export type GeneratedConfigBundle = BrandedGeneratedConfigBundle | DefaultGeneratedConfigBundle; + +const DEFAULT_BRAND_ID = 'linkcode'; +const BUNDLE_FILE = 'config-build-bundle.json'; +const BRAND_IDENTITY_FILE = 'brand-identity.json'; +const BRAND_BUILDER_FILE = 'electron-builder.brand.json'; +const BRAND_ICON_FILE = 'brand-assets/icon.png'; + +function listFiles(dir: string, prefix = ''): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) appendArrayInPlace(files, listFiles(join(dir, entry.name), relative)); + else files.push(relative); + } + return files; +} + +function assertExactFiles(dir: string, expected: readonly string[], label: string): void { + const actual = listFiles(dir); + if ( + actual.length === expected.length && + actual.every((file, index) => file === expected[index]) + ) { + return; + } + throw new Error( + `${label} must contain exactly ${expected.length === 0 ? 'no files' : expected.join(', ')}; ` + + 're-run the matching config render/build before packaging', + ); +} export function loadGeneratedConfigBundle( desktopDir: string, env: Readonly>>, ): GeneratedConfigBundle | null { - const bundlePath = resolve(desktopDir, 'generated/config-build-bundle.json'); - const brandIdentityPath = resolve(desktopDir, 'generated/brand-identity.json'); + if (env.MAIN_VITE_BRAND_IDENTITY) { + throw new Error( + 'MAIN_VITE_BRAND_IDENTITY must not be set; desktop identity comes only from generated ' + + 'brand artifacts or the built-in default', + ); + } + const generatedDir = resolve(desktopDir, 'generated'); + const bundlePath = resolve(generatedDir, BUNDLE_FILE); if (!existsSync(bundlePath)) { + assertExactFiles(generatedDir, [], 'apps/desktop/generated without a config bundle'); if (env.LINKCODE_REQUIRE_CONFIG_BUNDLE === '1') { throw new Error( 'LINKCODE_REQUIRE_CONFIG_BUNDLE=1 but apps/desktop/generated has no bundle — run ' + '`pnpm -F @linkcode/desktop config:render` with pinned inputs before building', ); } - if (existsSync(brandIdentityPath)) { - throw new Error( - 'apps/desktop/generated has a brand identity but no config bundle — re-run ' + - '`pnpm -F @linkcode/desktop config:render --brand-artifacts` with pinned inputs', - ); - } return null; } if (env.MAIN_VITE_CONFIG_BOOTSTRAP) { @@ -75,18 +124,23 @@ export function loadGeneratedConfigBundle( 'LINKCODE_REQUIRE_CONFIG_BUNDLE=1 requires an emergency endpoint and emergency public key', ); } - // White-label renders also write the immutable identity artifact; when present it must be the - // same brand/channel/source as the bundle and no ambient override may exist. Deep validation - // (parseBrandIdentityArtifact) throws on any malformed or tampered artifact. - let brandIdentityJson: string | undefined; - if (existsSync(brandIdentityPath)) { - if (env.MAIN_VITE_BRAND_IDENTITY) { - throw new Error( - 'MAIN_VITE_BRAND_IDENTITY must not be set when a generated brand identity exists; ' + - 'the generated brand identity is immutable', - ); - } - brandIdentityJson = readFileSync(brandIdentityPath, 'utf8'); + const branded = bundle.brandId !== DEFAULT_BRAND_ID; + assertExactFiles( + generatedDir, + branded + ? [BRAND_ICON_FILE, BRAND_IDENTITY_FILE, BUNDLE_FILE, BRAND_BUILDER_FILE] + : [BUNDLE_FILE], + 'apps/desktop/generated', + ); + + let brand: + | Pick< + BrandedGeneratedConfigBundle, + 'brandBuilderConfigText' | 'brandIconBytes' | 'brandIdentityJson' + > + | undefined; + if (branded) { + const brandIdentityJson = readFileSync(resolve(generatedDir, BRAND_IDENTITY_FILE), 'utf8'); const identity = parseBrandIdentityArtifact(JSON.parse(brandIdentityJson)); if (identity.platform !== 'desktop') { throw new Error(`generated brand identity targets ${identity.platform}, expected desktop`); @@ -102,6 +156,21 @@ export function loadGeneratedConfigBundle( '`pnpm -F @linkcode/desktop config:render --brand-artifacts`', ); } + const brandBuilderConfigText = readFileSync(resolve(generatedDir, BRAND_BUILDER_FILE), 'utf8'); + const expectedBuilderConfig = serializeElectronBuilderBrandConfig( + electronBuilderBrandConfig(identity), + ); + if (brandBuilderConfigText !== expectedBuilderConfig) { + throw new Error( + 'generated electron-builder brand config does not match brand-identity.json — re-run ' + + '`pnpm -F @linkcode/desktop config:render --brand-artifacts`', + ); + } + brand = { + brandBuilderConfigText, + brandIconBytes: readFileSync(resolve(generatedDir, BRAND_ICON_FILE)), + brandIdentityJson, + }; } // Same shape as DesktopConfigBootstrap (src/main/config.ts); parseBootstrap revalidates it at // runtime after Vite inlines it into the main bundle. @@ -116,7 +185,11 @@ export function loadGeneratedConfigBundle( publicKeys: bundle.keyrings.normal, telemetryEndpoint: bundle.endpoints.telemetry, }; - return { bootstrapJson: JSON.stringify(bootstrap), brandIdentityJson, bundleText }; + const generatedBase = { + bootstrapJson: JSON.stringify(bootstrap), + bundleText, + }; + return brand === undefined ? generatedBase : { ...generatedBase, ...brand }; } /** @@ -133,4 +206,9 @@ export function stageConfigBundle( if (!generated) return; mkdirSync(outConfig, { recursive: true }); writeFileSync(resolve(outConfig, 'build-bundle.json'), generated.bundleText); + if (generated.brandIdentityJson === undefined) return; + writeFileSync(resolve(outConfig, BRAND_IDENTITY_FILE), generated.brandIdentityJson); + writeFileSync(resolve(outConfig, BRAND_BUILDER_FILE), generated.brandBuilderConfigText); + mkdirSync(resolve(outConfig, 'brand-assets'), { recursive: true }); + writeFileSync(resolve(outConfig, BRAND_ICON_FILE), generated.brandIconBytes); } diff --git a/apps/desktop/scripts/package-app.mts b/apps/desktop/scripts/package-app.mts index 568ca5a5..23658340 100644 --- a/apps/desktop/scripts/package-app.mts +++ b/apps/desktop/scripts/package-app.mts @@ -28,6 +28,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; import crossSpawn from 'cross-spawn'; +import { assertStagedConfigMatchesGenerated } from './package-config.mts'; import { mergeUpdateFeeds } from './update-feed.mts'; const HOST_PLATFORM: Partial> = { @@ -166,11 +167,7 @@ const IDENTITY_OVERRIDE_RE = /^-c\.(?:appId|productName|protocols)\b/; function build(): void { // Both extend the shared electron-builder.yml base; each adds its own deep-link scheme (release // `linkcode://`, dev shell `linkcode-dev://`). The base is never passed directly — it has none. - // A branded build (config:render wrote generated/electron-builder.brand.json) uses the generated - // overlay instead: appId, productName, protocol scheme, and icons all come from the rendered - // brand identity, never from hand-edited YAML. - const brandConfig = join(desktopDir, 'generated', 'electron-builder.brand.json'); - const branded = existsSync(brandConfig); + const branded = assertStagedConfigMatchesGenerated(desktopDir); if (branded && devshell) { // out/ already embeds the branded bootstrap+identity; packing it as a dev shell would mix // the LinkCode Development shell identity with another brand's runtime identity. @@ -179,6 +176,7 @@ function build(): void { '--devshell) — a dev shell must not embed another brand', ); } + const brandConfig = join(desktopDir, 'out', 'config', 'electron-builder.brand.json'); if (branded) { const rejected = passthrough.find((arg) => IDENTITY_OVERRIDE_RE.test(arg)); if (rejected !== undefined) { @@ -190,7 +188,7 @@ function build(): void { : branded ? brandConfig : 'electron-builder.release.yml'; - const brandIcon = join(desktopDir, 'generated', 'brand-assets', 'icon.png'); + const brandIcon = join(desktopDir, 'out', 'config', 'brand-assets', 'icon.png'); const feeds = new Map(); for (const arch of stagedArches()) { const target = materializeStaging(arch); diff --git a/apps/desktop/scripts/package-config.mts b/apps/desktop/scripts/package-config.mts new file mode 100644 index 00000000..f79cc829 --- /dev/null +++ b/apps/desktop/scripts/package-config.mts @@ -0,0 +1,75 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { appendArrayInPlace } from 'foxts/append-array-in-place'; + +const CONFIG_FILE_PAIRS = [ + ['config-build-bundle.json', 'build-bundle.json'], + ['brand-identity.json', 'brand-identity.json'], + ['electron-builder.brand.json', 'electron-builder.brand.json'], + ['brand-assets/icon.png', 'brand-assets/icon.png'], +] as const; + +function listFiles(dir: string, prefix = ''): string[] { + if (!existsSync(dir)) return []; + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name), + )) { + const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory()) appendArrayInPlace(files, listFiles(join(dir, entry.name), relative)); + else files.push(relative); + } + return files; +} + +function assertFiles(actual: readonly string[], expected: readonly string[], label: string): void { + if ( + actual.length === expected.length && + actual.every((file, index) => file === expected[index]) + ) { + return; + } + throw new Error( + `${label} does not match a complete desktop config build — rebuild before packaging`, + ); +} + +/** Refuses packaging when generated inputs no longer match the files staged by the Vite build. */ +export function assertStagedConfigMatchesGenerated(desktopDir: string): boolean { + const generatedDir = join(desktopDir, 'generated'); + const outConfig = join(desktopDir, 'out', 'config'); + const generatedFiles = listFiles(generatedDir); + const branded = generatedFiles.includes('brand-identity.json'); + if (generatedFiles.includes('config-build-bundle.json')) { + const bundle = JSON.parse( + readFileSync(join(generatedDir, 'config-build-bundle.json'), 'utf8'), + ) as { brandId?: unknown }; + if (typeof bundle.brandId !== 'string' || (bundle.brandId !== 'linkcode') !== branded) { + throw new Error( + 'apps/desktop/generated brand artifacts do not match the config bundle — rebuild before packaging', + ); + } + } + const pairs = + generatedFiles.length === 0 ? [] : branded ? CONFIG_FILE_PAIRS : CONFIG_FILE_PAIRS.slice(0, 1); + assertFiles( + generatedFiles, + pairs.map(([generated]) => generated).sort(), + 'apps/desktop/generated', + ); + assertFiles( + listFiles(outConfig), + pairs.map(([, staged]) => staged).sort(), + 'apps/desktop/out/config', + ); + for (const [generated, staged] of pairs) { + if ( + !readFileSync(join(generatedDir, generated)).equals(readFileSync(join(outConfig, staged))) + ) { + throw new Error( + `apps/desktop/out/config/${staged} does not match generated/${generated} — rebuild before packaging`, + ); + } + } + return branded; +} diff --git a/apps/desktop/src/__tests__/config-bundle.test.ts b/apps/desktop/src/__tests__/config-bundle.test.ts index f5cef245..8b879973 100644 --- a/apps/desktop/src/__tests__/config-bundle.test.ts +++ b/apps/desktop/src/__tests__/config-bundle.test.ts @@ -2,13 +2,24 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { parseBrandIdentityArtifact } from '@linkcode/common/config'; import { keysLength } from 'foxts/property-count'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { loadGeneratedConfigBundle, stageConfigBundle } from '../../scripts/config-bundle.mts'; +import { assertStagedConfigMatchesGenerated } from '../../scripts/package-config.mts'; +import { + electronBuilderBrandConfig, + serializeElectronBuilderBrandConfig, +} from '../build/electron-builder-brand'; const RE_REQUIRED_ABSENT = /LINKCODE_REQUIRE_CONFIG_BUNDLE=1 but apps\/desktop\/generated has no bundle/; +const RE_AMBIENT_IDENTITY = /MAIN_VITE_BRAND_IDENTITY must not be set/; +const RE_BRAND_ARTIFACT_MISMATCH = /brand artifacts do not match the config bundle/; +const RE_BUILDER_MISMATCH = /electron-builder brand config does not match/; +const RE_INCOMPLETE = /must contain exactly/; const RE_IMMUTABLE = /immutable/; +const RE_REBUILD = /rebuild before packaging/; const RE_WRONG_PLATFORM = /targets ios, expected desktop/; const RE_FIXTURE_KEY = /conformance fixture key/; const RE_EMERGENCY_BOOTSTRAP = /requires an emergency endpoint and emergency public key/; @@ -37,16 +48,39 @@ const FIXTURES = join( ); const desktopFixture = readFileSync(join(FIXTURES, 'build-bundle-v1.json'), 'utf8'); const iosFixture = readFileSync(join(FIXTURES, 'build-bundle-v1-ios.json'), 'utf8'); +const brandIdentityFixture = readFileSync(join(FIXTURES, 'brand-identity-v1.json'), 'utf8'); +const brandBuilderFixture = serializeElectronBuilderBrandConfig( + electronBuilderBrandConfig(parseBrandIdentityArtifact(JSON.parse(brandIdentityFixture))), +); +const brandIconFixture = Buffer.from('test brand icon'); const temporaryDirectories: string[] = []; -async function makeDesktopDir(bundleText?: string): Promise { +function isBrandedBundle(bundleText: string | undefined): boolean { + if (bundleText === undefined) return false; + try { + return (JSON.parse(bundleText) as { brandId?: unknown }).brandId !== 'linkcode'; + } catch { + return false; + } +} + +async function makeDesktopDir( + bundleText?: string, + brandArtifacts = isBrandedBundle(bundleText), +): Promise { const dir = await mkdtemp(join(tmpdir(), 'config-bundle-')); temporaryDirectories.push(dir); if (bundleText !== undefined) { mkdirSync(join(dir, 'generated'), { recursive: true }); writeFileSync(join(dir, 'generated/config-build-bundle.json'), bundleText); } + if (brandArtifacts) { + mkdirSync(join(dir, 'generated/brand-assets'), { recursive: true }); + writeFileSync(join(dir, 'generated/brand-identity.json'), brandIdentityFixture); + writeFileSync(join(dir, 'generated/electron-builder.brand.json'), brandBuilderFixture); + writeFileSync(join(dir, 'generated/brand-assets/icon.png'), brandIconFixture); + } return dir; } @@ -80,6 +114,13 @@ describe('loadGeneratedConfigBundle', () => { expect(loadGeneratedConfigBundle(dir, {})).toBeNull(); }); + it('rejects ambient brand identity even without generated output', async () => { + const dir = await makeDesktopDir(); + expect(() => + loadGeneratedConfigBundle(dir, { MAIN_VITE_BRAND_IDENTITY: '{"brandId":"fake"}' }), + ).toThrow(RE_AMBIENT_IDENTITY); + }); + it('fails when LINKCODE_REQUIRE_CONFIG_BUNDLE=1 and no bundle exists', async () => { const dir = await makeDesktopDir(); expect(() => loadGeneratedConfigBundle(dir, { LINKCODE_REQUIRE_CONFIG_BUNDLE: '1' })).toThrow( @@ -166,6 +207,18 @@ describe('loadGeneratedConfigBundle', () => { }, ); + it('rejects a non-default bundle without the complete brand artifact set', async () => { + const dir = await makeDesktopDir(validDesktopFixture, false); + expect(() => loadGeneratedConfigBundle(dir, {})).toThrow(RE_INCOMPLETE); + expect(() => assertStagedConfigMatchesGenerated(dir)).toThrow(RE_BRAND_ARTIFACT_MISMATCH); + }); + + it('rejects a builder overlay that does not match the rendered identity', async () => { + const dir = await makeDesktopDir(validDesktopFixture); + writeFileSync(join(dir, 'generated/electron-builder.brand.json'), '{}\n'); + expect(() => loadGeneratedConfigBundle(dir, {})).toThrow(RE_BUILDER_MISMATCH); + }); + it('derives a bootstrap that the runtime parser accepts, with exact source bytes', async () => { const dir = await makeDesktopDir(validDesktopFixture); const generated = loadGeneratedConfigBundle(dir, {}); @@ -203,8 +256,25 @@ describe('stageConfigBundle', () => { const dir = await makeDesktopDir(validDesktopFixture); const generated = loadGeneratedConfigBundle(dir, {}); stageConfigBundle(dir, generated); - const staged = readFileSync(join(dir, 'out/config/build-bundle.json'), 'utf8'); - expect(staged).toBe(validDesktopFixture); + expect(readFileSync(join(dir, 'out/config/build-bundle.json'), 'utf8')).toBe( + validDesktopFixture, + ); + expect(readFileSync(join(dir, 'out/config/brand-identity.json'), 'utf8')).toBe( + brandIdentityFixture, + ); + expect(readFileSync(join(dir, 'out/config/electron-builder.brand.json'), 'utf8')).toBe( + brandBuilderFixture, + ); + expect(readFileSync(join(dir, 'out/config/brand-assets/icon.png'))).toEqual(brandIconFixture); + expect(assertStagedConfigMatchesGenerated(dir)).toBe(true); + }); + + it('rejects packaging after generated artifacts change without a rebuild', async () => { + const dir = await makeDesktopDir(validDesktopFixture); + const generated = loadGeneratedConfigBundle(dir, {}); + stageConfigBundle(dir, generated); + writeFileSync(join(dir, 'generated/brand-identity.json'), brandIdentityFixture.trim()); + expect(() => assertStagedConfigMatchesGenerated(dir)).toThrow(RE_REBUILD); }); it('removes a stale staged copy when no bundle is rendered', async () => { @@ -213,5 +283,6 @@ describe('stageConfigBundle', () => { writeFileSync(join(dir, 'out/config/build-bundle.json'), 'stale'); stageConfigBundle(dir, null); expect(existsSync(join(dir, 'out/config'))).toBe(false); + expect(assertStagedConfigMatchesGenerated(dir)).toBe(false); }); }); diff --git a/apps/desktop/turbo.json b/apps/desktop/turbo.json index 9f198bb7..5b8f72f8 100644 --- a/apps/desktop/turbo.json +++ b/apps/desktop/turbo.json @@ -5,12 +5,13 @@ "build": { "env": [ "LINKCODE_REQUIRE_CONFIG_BUNDLE", + "MAIN_VITE_BRAND_IDENTITY", "MAIN_VITE_CONFIG_BOOTSTRAP", "MAIN_VITE_SENTRY_DSN", "RENDERER_VITE_POSTHOG_PROJECT_TOKEN", "RENDERER_VITE_POSTHOG_HOST" ], - "inputs": ["$TURBO_DEFAULT$", "generated/config-build-bundle.json"] + "inputs": ["$TURBO_DEFAULT$", "generated/**"] } } } From 99bc3ffcf24485086b4414029e0c0577fd72708a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:54:41 +0000 Subject: [PATCH 08/20] feat(release): validate brand build matrix Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/scripts/brand-matrix.cjs | 341 +++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 .github/scripts/brand-matrix.cjs diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs new file mode 100644 index 00000000..7710056e --- /dev/null +++ b/.github/scripts/brand-matrix.cjs @@ -0,0 +1,341 @@ +const process = require('node:process'); + +const BUILD_MATRIX_VERSION = 1; +const PLATFORMS = ['desktop', 'ios', 'android']; +const CHECKLIST_KEYS = [ + 'configurableFeaturesDisclosed', + 'dataPracticesReviewed', + 'noExecutableCode', + 'permissionsReviewed', + 'storeMetadataReviewed', +]; +const RELEASE_MANIFEST_KEYS = [ + 'brandId', + 'channel', + 'configRevisionId', + 'expectedSnapshotSha256', + 'platform', + 'publicKeyringsSha256', + 'publisherGitSha', + 'releaseManifestFormatVersion', + 'revisionSha256', + 'sourceGitSha', + 'telemetryEndpoint', +]; + +const RE_BRAND_ID = /^[a-z][a-z0-9-]{0,62}$/; +const RE_GIT_SHA = /^[0-9a-f]{40}$/; +const RE_SHA256 = /^[0-9a-f]{64}$/; +const RE_REVISION = /^[A-Z0-9][\w.-]{0,127}$/i; +const RE_BUCKET = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/; +const RE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const RE_DISCLOSED_FEATURE = /^(?:feature|modules)\.[\w.-]+$/; +const RE_R2_PREFIX = /^[a-z0-9][a-z0-9/-]*$/; +const RE_TRAILING_SLASH = /\/$/; +const RE_TEAM_ID = /^[A-Z0-9]{10}$/; +const RE_ASC_APP_ID = /^\d+$/; +const RE_SECRET_PREFIX = /^[A-Z][A-Z0-9_]{1,31}$/; + +function fail(path, message) { + throw new TypeError(`${path}: ${message}`); +} + +function record(value, path) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail(path, 'must be an object'); + } + return value; +} + +function exact(value, keys, path) { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + fail(path, `must contain exactly: ${expected.join(', ')}`); + } +} + +function string(value, path, pattern) { + if (typeof value !== 'string' || value.length === 0) fail(path, 'must be a non-empty string'); + if (pattern && !pattern.test(value)) fail(path, 'has an invalid format'); + return value; +} + +function httpsUrl(value, path) { + const text = string(value, path); + let url; + try { + url = new URL(text); + } catch { + fail(path, 'must be an absolute HTTPS URL'); + } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + fail(path, 'must be HTTPS without credentials, query, or fragment'); + } + return text; +} + +function releaseManifest(value, path, platform, brandId, channel) { + const manifest = record(value, path); + exact(manifest, RELEASE_MANIFEST_KEYS, path); + if (manifest.releaseManifestFormatVersion !== 1) fail(path, 'format version must be 1'); + for (const field of ['brandId', 'channel', 'configRevisionId', 'platform']) { + string(manifest[field], `${path}.${field}`); + } + for (const field of ['publisherGitSha', 'sourceGitSha']) { + string(manifest[field], `${path}.${field}`, RE_GIT_SHA); + } + for (const field of ['expectedSnapshotSha256', 'publicKeyringsSha256', 'revisionSha256']) { + string(manifest[field], `${path}.${field}`, RE_SHA256); + } + string(manifest.configRevisionId, `${path}.configRevisionId`, RE_REVISION); + httpsUrl(manifest.telemetryEndpoint, `${path}.telemetryEndpoint`); + if ( + manifest.brandId !== brandId || + manifest.channel !== channel || + manifest.platform !== platform + ) { + fail(path, `must target ${brandId}/${platform}/${channel}`); + } + return manifest; +} + +function compliance(value, path) { + const declaration = record(value, path); + exact(declaration, ['checklist', 'disclosedFeatures'], path); + if (!Array.isArray(declaration.disclosedFeatures)) { + fail(`${path}.disclosedFeatures`, 'must be an array'); + } + const features = declaration.disclosedFeatures.map((entry, index) => + string(entry, `${path}.disclosedFeatures[${index}]`, RE_DISCLOSED_FEATURE), + ); + if ( + new Set(features).size !== features.length || + features.some((entry, i) => entry !== [...features].sort()[i]) + ) { + fail(`${path}.disclosedFeatures`, 'must be unique and lexicographically sorted'); + } + const checklist = record(declaration.checklist, `${path}.checklist`); + exact(checklist, CHECKLIST_KEYS, `${path}.checklist`); + for (const key of CHECKLIST_KEYS) { + if (checklist[key] !== true) fail(`${path}.checklist.${key}`, 'must be true'); + } + return declaration; +} + +function desktopDistribution(value, path, brandId, channel) { + if (value === null) return null; + const distribution = record(value, path); + exact(distribution, ['credentialSecretPrefix', 'r2Bucket', 'r2Prefix', 'updateUrl'], path); + const updateUrl = httpsUrl(distribution.updateUrl, `${path}.updateUrl`); + const credentialSecretPrefix = string( + distribution.credentialSecretPrefix, + `${path}.credentialSecretPrefix`, + RE_SECRET_PREFIX, + ); + const r2Bucket = string(distribution.r2Bucket, `${path}.r2Bucket`, RE_BUCKET); + const r2Prefix = string(distribution.r2Prefix, `${path}.r2Prefix`, RE_R2_PREFIX); + const expectedSuffix = `/${r2Prefix.replace(RE_TRAILING_SLASH, '')}`; + if (!r2Prefix.split('/').includes(brandId) || !r2Prefix.split('/').includes(channel)) { + fail(`${path}.r2Prefix`, 'must include the brand id and channel as path segments'); + } + if (!new URL(updateUrl).pathname.replace(RE_TRAILING_SLASH, '').endsWith(expectedSuffix)) { + fail(path, 'updateUrl path must end with r2Prefix'); + } + return { + credentialSecretPrefix, + r2Bucket, + r2Prefix: r2Prefix.replace(RE_TRAILING_SLASH, ''), + updateUrl, + }; +} + +function mobileDistribution(value, path) { + if (value === null) return null; + const distribution = record(value, path); + exact(distribution, ['android', 'easProjectId', 'ios', 'updatesUrl'], path); + const easProjectId = string(distribution.easProjectId, `${path}.easProjectId`, RE_UUID); + const updatesUrl = httpsUrl(distribution.updatesUrl, `${path}.updatesUrl`); + if (updatesUrl !== `https://u.expo.dev/${easProjectId}`) { + fail(`${path}.updatesUrl`, 'must be the EAS update URL for easProjectId'); + } + const ios = record(distribution.ios, `${path}.ios`); + exact(ios, ['appleTeamId', 'ascAppId'], `${path}.ios`); + string(ios.appleTeamId, `${path}.ios.appleTeamId`, RE_TEAM_ID); + string(ios.ascAppId, `${path}.ios.ascAppId`, RE_ASC_APP_ID); + const android = record(distribution.android, `${path}.android`); + exact(android, ['track'], `${path}.android`); + if (android.track !== 'internal') fail(`${path}.android.track`, 'must be internal'); + return distribution; +} + +function parseBrandBuildMatrix(value, options = {}) { + const matrix = structuredClone(record(value, 'matrix')); + exact(matrix, ['brandBuildMatrixVersion', 'brands'], 'matrix'); + if (matrix.brandBuildMatrixVersion !== BUILD_MATRIX_VERSION) { + fail('matrix.brandBuildMatrixVersion', 'must be 1'); + } + if (!Array.isArray(matrix.brands) || matrix.brands.length === 0) { + fail('matrix.brands', 'must be a non-empty array'); + } + if (options.sign && !options.build) fail('options.sign', 'sign requires build=true'); + if (options.upload && !options.sign) fail('options.upload', 'upload requires sign=true'); + const seenBrands = new Set(); + const destinations = []; + const credentialPrefixes = new Set(); + const projects = new Set(); + const appStoreApps = new Set(); + const brands = matrix.brands.map((raw, index) => { + const path = `matrix.brands[${index}]`; + const brand = record(raw, path); + exact(brand, ['brandId', 'channel', 'compliance', 'distribution', 'releaseManifests'], path); + const brandId = string(brand.brandId, `${path}.brandId`, RE_BRAND_ID); + if (seenBrands.has(brandId)) fail(`${path}.brandId`, 'must be unique'); + seenBrands.add(brandId); + if (brand.channel !== 'canary' && brand.channel !== 'stable') { + fail(`${path}.channel`, 'must be canary or stable'); + } + const manifests = record(brand.releaseManifests, `${path}.releaseManifests`); + exact(manifests, PLATFORMS, `${path}.releaseManifests`); + const declarations = record(brand.compliance, `${path}.compliance`); + exact(declarations, PLATFORMS, `${path}.compliance`); + for (const platform of PLATFORMS) { + manifests[platform] = releaseManifest( + manifests[platform], + `${path}.releaseManifests.${platform}`, + platform, + brandId, + brand.channel, + ); + declarations[platform] = compliance(declarations[platform], `${path}.compliance.${platform}`); + } + for (const field of [ + 'publisherGitSha', + 'sourceGitSha', + 'configRevisionId', + 'revisionSha256', + 'publicKeyringsSha256', + ]) { + if (PLATFORMS.some((platform) => manifests[platform][field] !== manifests.desktop[field])) { + fail(`${path}.releaseManifests`, `all platforms must share ${field}`); + } + } + const distribution = record(brand.distribution, `${path}.distribution`); + exact(distribution, ['desktop', 'mobile'], `${path}.distribution`); + distribution.desktop = desktopDistribution( + distribution.desktop, + `${path}.distribution.desktop`, + brandId, + brand.channel, + ); + distribution.mobile = mobileDistribution(distribution.mobile, `${path}.distribution.mobile`); + if (options.build && (distribution.desktop === null || distribution.mobile === null)) { + fail( + `${path}.distribution`, + 'desktop and mobile delivery inputs are required when build=true', + ); + } + if (distribution.desktop) { + const collision = destinations.some( + ({ bucket, prefix }) => + bucket === distribution.desktop.r2Bucket && + (prefix === distribution.desktop.r2Prefix || + prefix.startsWith(`${distribution.desktop.r2Prefix}/`) || + distribution.desktop.r2Prefix.startsWith(`${prefix}/`)), + ); + if (collision) { + fail(`${path}.distribution.desktop`, 'R2 prefixes in one bucket must not overlap'); + } + } + if ( + distribution.desktop && + credentialPrefixes.has(distribution.desktop.credentialSecretPrefix) + ) { + fail(`${path}.distribution.desktop.credentialSecretPrefix`, 'must be unique'); + } + if (distribution.mobile && projects.has(distribution.mobile.easProjectId)) { + fail(`${path}.distribution.mobile.easProjectId`, 'must be unique'); + } + if (distribution.mobile && appStoreApps.has(distribution.mobile.ios.ascAppId)) { + fail(`${path}.distribution.mobile.ios.ascAppId`, 'must be unique'); + } + if (distribution.desktop) { + destinations.push({ + bucket: distribution.desktop.r2Bucket, + prefix: distribution.desktop.r2Prefix, + }); + credentialPrefixes.add(distribution.desktop.credentialSecretPrefix); + } + if (distribution.mobile) { + projects.add(distribution.mobile.easProjectId); + appStoreApps.add(distribution.mobile.ios.ascAppId); + } + return brand; + }); + return { brandBuildMatrixVersion: BUILD_MATRIX_VERSION, brands }; +} + +function buildMatrixPlan(matrix, options = {}) { + const parsed = parseBrandBuildMatrix(matrix, options); + return { + brands: { include: parsed.brands }, + targets: { + include: parsed.brands.flatMap((brand) => + PLATFORMS.map((platform) => ({ brandId: brand.brandId, channel: brand.channel, platform })), + ), + }, + }; +} + +function strictBoolean(value, name) { + if (value === 'true') return true; + if (value === 'false') return false; + fail(name, 'must be true or false'); +} + +function runCli(argv = process.argv.slice(2), env = process.env) { + const { appendFileSync, readFileSync } = require('node:fs'); + const { parseArgs } = require('node:util'); + const { values } = parseArgs({ + args: argv, + options: { + build: { type: 'string', default: 'false' }, + 'matrix-file': { type: 'string' }, + sign: { type: 'string', default: 'false' }, + upload: { type: 'string', default: 'false' }, + }, + strict: true, + }); + const text = values['matrix-file'] + ? readFileSync(values['matrix-file'], 'utf8') + : env.BRAND_BUILD_MATRIX; + if (!text) fail('BRAND_BUILD_MATRIX', 'must be set or supplied with --matrix-file'); + let matrix; + try { + matrix = JSON.parse(text); + } catch { + fail('BRAND_BUILD_MATRIX', 'must be valid JSON'); + } + const plan = buildMatrixPlan(matrix, { + build: strictBoolean(values.build, '--build'), + sign: strictBoolean(values.sign, '--sign'), + upload: strictBoolean(values.upload, '--upload'), + }); + const outputs = [ + `brands=${JSON.stringify(plan.brands)}`, + `targets=${JSON.stringify(plan.targets)}`, + ]; + if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${outputs.join('\n')}\n`); + else console.log(outputs.join('\n')); + return plan; +} + +if (require.main === module) runCli(); + +module.exports = { + BUILD_MATRIX_VERSION, + CHECKLIST_KEYS, + PLATFORMS, + buildMatrixPlan, + parseBrandBuildMatrix, +}; From 541bdcfcfb77cb50ae1a240afb12ccf7957351c2 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:55:04 +0000 Subject: [PATCH 09/20] test(release): cover brand matrix isolation Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/scripts/brand-matrix.test.mjs | 211 ++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 .github/scripts/brand-matrix.test.mjs diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs new file mode 100644 index 00000000..ab34d3f7 --- /dev/null +++ b/.github/scripts/brand-matrix.test.mjs @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; +import matrixModule from './brand-matrix.cjs'; + +const { buildMatrixPlan, parseBrandBuildMatrix } = matrixModule; +const RE_WRONG_BRAND = /must target acme\/ios\/canary/; +const RE_WRONG_PLATFORM = /must target acme\/android\/canary/; +const RE_UNCHECKED = /noExecutableCode: must be true/; +const RE_INVALID_FORMAT = /has an invalid format/; +const RE_MISSING_DELIVERY = /delivery inputs are required/; +const RE_SIGN_WITHOUT_BUILD = /sign requires build=true/; +const RE_UPLOAD_WITHOUT_SIGN = /upload requires sign=true/; +const RE_MISSING_BRAND_SEGMENT = /must include the brand id/; +const RE_UNKNOWN_FIELD = /must contain exactly/; +const RE_DIVERGENT_SOURCE = /all platforms must share sourceGitSha/; +const RE_SHARED_DESTINATION = /R2 prefixes in one bucket must not overlap/; +const RE_SHARED_CREDENTIALS = /credentialSecretPrefix: must be unique/; +const RE_SHARED_APP_STORE_APP = /ios\.ascAppId: must be unique/; + +function sha(character) { + return character.repeat(64); +} + +function gitSha(character) { + return character.repeat(40); +} + +function checklist() { + return { + configurableFeaturesDisclosed: true, + dataPracticesReviewed: true, + noExecutableCode: true, + permissionsReviewed: true, + storeMetadataReviewed: true, + }; +} + +function manifest(brandId, platform) { + return { + brandId, + channel: 'canary', + configRevisionId: 'fixture-v1', + expectedSnapshotSha256: sha('a'), + platform, + publicKeyringsSha256: sha('b'), + publisherGitSha: gitSha('c'), + releaseManifestFormatVersion: 1, + revisionSha256: sha('d'), + sourceGitSha: gitSha('e'), + telemetryEndpoint: `https://${brandId}.example.invalid/telemetry`, + }; +} + +function brand(brandId = 'acme') { + const declaration = { + checklist: checklist(), + disclosedFeatures: ['feature.aiAssist', 'modules.gitLab'], + }; + return { + brandId, + channel: 'canary', + compliance: { + android: structuredClone(declaration), + desktop: structuredClone(declaration), + ios: structuredClone(declaration), + }, + distribution: { desktop: null, mobile: null }, + releaseManifests: { + android: manifest(brandId, 'android'), + desktop: manifest(brandId, 'desktop'), + ios: manifest(brandId, 'ios'), + }, + }; +} + +function matrix(...brands) { + return { brandBuildMatrixVersion: 1, brands }; +} + +describe('parseBrandBuildMatrix', () => { + it('builds the complete brand by platform plan', () => { + const input = matrix(brand('acme'), brand('zenith')); + const plan = buildMatrixPlan(input); + expect( + plan.targets.include.map(({ brandId, platform }) => `${brandId}/${platform}`), + ).toStrictEqual([ + 'acme/desktop', + 'acme/ios', + 'acme/android', + 'zenith/desktop', + 'zenith/ios', + 'zenith/android', + ]); + expect(input.brands[0].distribution).toStrictEqual({ desktop: null, mobile: null }); + }); + + it('rejects cross-brand and cross-platform manifest bindings', () => { + const wrongBrand = matrix(brand()); + wrongBrand.brands[0].releaseManifests.ios.brandId = 'zenith'; + expect(() => parseBrandBuildMatrix(wrongBrand)).toThrow(RE_WRONG_BRAND); + + const wrongPlatform = matrix(brand()); + wrongPlatform.brands[0].releaseManifests.android.platform = 'ios'; + expect(() => parseBrandBuildMatrix(wrongPlatform)).toThrow(RE_WRONG_PLATFORM); + }); + + it('rejects undisclosed checklist state and non-feature disclosure keys', () => { + const unchecked = matrix(brand()); + unchecked.brands[0].compliance.ios.checklist.noExecutableCode = false; + expect(() => parseBrandBuildMatrix(unchecked)).toThrow(RE_UNCHECKED); + + const invalidDisclosure = matrix(brand()); + invalidDisclosure.brands[0].compliance.android.disclosedFeatures = ['review.hiddenMode']; + expect(() => parseBrandBuildMatrix(invalidDisclosure)).toThrow(RE_INVALID_FORMAT); + }); + + it('rejects missing delivery inputs when building or signing is requested', () => { + expect(() => parseBrandBuildMatrix(matrix(brand()), { build: true })).toThrow( + RE_MISSING_DELIVERY, + ); + expect(() => parseBrandBuildMatrix(matrix(brand()), { sign: true })).toThrow( + RE_SIGN_WITHOUT_BUILD, + ); + expect(() => parseBrandBuildMatrix(matrix(brand()), { upload: true })).toThrow( + RE_UPLOAD_WITHOUT_SIGN, + ); + + const first = brand('acme'); + first.distribution.desktop = { + credentialSecretPrefix: 'ACME', + r2Bucket: 'release-acme', + r2Prefix: 'desktop/acme/canary', + updateUrl: 'https://acme.example.invalid/desktop/acme/canary', + }; + first.distribution.mobile = { + android: { track: 'internal' }, + easProjectId: '11111111-1111-4111-8111-111111111111', + ios: { appleTeamId: 'ABC1234567', ascAppId: '1234567890' }, + updatesUrl: 'https://u.expo.dev/11111111-1111-4111-8111-111111111111', + }; + const second = structuredClone(first); + second.brandId = 'zenith'; + for (const platform of ['desktop', 'ios', 'android']) { + second.releaseManifests[platform].brandId = 'zenith'; + } + expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( + RE_MISSING_BRAND_SEGMENT, + ); + }); + + it('rejects shared R2 destinations, credentials, and store apps across brands', () => { + const first = brand('acme'); + first.distribution.desktop = { + credentialSecretPrefix: 'ACME', + r2Bucket: 'release-brands', + r2Prefix: 'desktop/acme/zenith/canary', + updateUrl: 'https://acme.example.invalid/desktop/acme/zenith/canary', + }; + first.distribution.mobile = { + android: { track: 'internal' }, + easProjectId: '11111111-1111-4111-8111-111111111111', + ios: { appleTeamId: 'ABC1234567', ascAppId: '1234567890' }, + updatesUrl: 'https://u.expo.dev/11111111-1111-4111-8111-111111111111', + }; + const second = brand('zenith'); + second.distribution.desktop = { + credentialSecretPrefix: 'ZENITH', + r2Bucket: first.distribution.desktop.r2Bucket, + r2Prefix: first.distribution.desktop.r2Prefix, + updateUrl: 'https://zenith.example.invalid/desktop/acme/zenith/canary', + }; + second.distribution.mobile = { + android: { track: 'internal' }, + easProjectId: '22222222-2222-4222-8222-222222222222', + ios: { appleTeamId: 'ABC1234567', ascAppId: '0987654321' }, + updatesUrl: 'https://u.expo.dev/22222222-2222-4222-8222-222222222222', + }; + expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( + RE_SHARED_DESTINATION, + ); + + second.distribution.desktop.r2Prefix = 'desktop/acme/zenith/canary/child'; + second.distribution.desktop.updateUrl = + 'https://zenith.example.invalid/desktop/acme/zenith/canary/child'; + expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( + RE_SHARED_DESTINATION, + ); + + second.distribution.desktop.r2Prefix = 'desktop/zenith/canary'; + second.distribution.desktop.updateUrl = 'https://zenith.example.invalid/desktop/zenith/canary'; + second.distribution.desktop.credentialSecretPrefix = 'ACME'; + expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( + RE_SHARED_CREDENTIALS, + ); + + second.distribution.desktop.credentialSecretPrefix = 'ZENITH'; + second.distribution.mobile.ios.ascAppId = first.distribution.mobile.ios.ascAppId; + expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( + RE_SHARED_APP_STORE_APP, + ); + }); + + it('rejects unknown fields and divergent immutable source bindings', () => { + const extra = matrix(brand()); + extra.brands[0].releaseManifests.desktop.hidden = true; + expect(() => parseBrandBuildMatrix(extra)).toThrow(RE_UNKNOWN_FIELD); + + const divergent = matrix(brand()); + divergent.brands[0].releaseManifests.ios.sourceGitSha = gitSha('f'); + expect(() => parseBrandBuildMatrix(divergent)).toThrow(RE_DIVERGENT_SOURCE); + }); +}); From 2633dbdbf53ceb2e76de4cc75d62db4a0ea4a6d9 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:55:12 +0000 Subject: [PATCH 10/20] feat(release): validate protected release inputs Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/scripts/release-inputs.cjs | 95 +++++++++++++++++++++++++ .github/scripts/release-inputs.test.mjs | 73 +++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 .github/scripts/release-inputs.cjs create mode 100644 .github/scripts/release-inputs.test.mjs diff --git a/.github/scripts/release-inputs.cjs b/.github/scripts/release-inputs.cjs new file mode 100644 index 00000000..2a780d3d --- /dev/null +++ b/.github/scripts/release-inputs.cjs @@ -0,0 +1,95 @@ +const { Buffer } = require('node:buffer'); +const process = require('node:process'); + +const PHASES = new Set(['render', 'sign', 'upload']); +const PLATFORMS = new Set(['desktop', 'mobile']); +const RE_R2_ACCOUNT_ID = /^[0-9a-f]{32}$/; +const INPUTS = { + render: [ + ['var', 'CONFIG_PUBLISHER_REPO'], + ['secret', 'CONFIG_PUBLISHER_TOKEN'], + ['var', 'CONFIG_RELEASE_KEYRINGS'], + ['var', 'CONFIG_RELEASE_REVISION'], + ], + sign: { + desktop: [ + ['secret', 'APPLE_API_KEY_BASE64'], + ['secret', 'APPLE_API_KEY_ID'], + ['secret', 'APPLE_API_ISSUER'], + ['secret', 'APPLE_TEAM_ID'], + ['secret', 'AZURE_CERTIFICATE_PROFILE'], + ['secret', 'AZURE_CLIENT_ID'], + ['secret', 'AZURE_CODE_SIGNING_ACCOUNT'], + ['secret', 'AZURE_PUBLISHER_NAME'], + ['secret', 'AZURE_SIGN_ENDPOINT'], + ['secret', 'AZURE_TENANT_ID'], + ['secret', 'MACOS_CSC_KEY_PASSWORD'], + ['secret', 'MACOS_CSC_LINK'], + ['var', 'POSTHOG_HOST'], + ['secret', 'POSTHOG_PROJECT_TOKEN'], + ['secret', 'SENTRY_DSN_DESKTOP'], + ], + mobile: [ + ['secret', 'EXPO_TOKEN'], + ['secret', 'POSTHOG_PROJECT_TOKEN'], + ['var', 'POSTHOG_HOST'], + ['secret', 'SENTRY_AUTH_TOKEN'], + ['secret', 'SENTRY_DSN_MOBILE'], + ], + }, + upload: { + desktop: [ + ['secret', 'R2_ACCESS_KEY_ID'], + ['secret', 'R2_ACCOUNT_ID'], + ['secret', 'R2_SECRET_ACCESS_KEY'], + ], + mobile: [['secret', 'EXPO_TOKEN']], + }, +}; + +function validateReleaseInputs({ env, phase, platform }) { + if (!PHASES.has(phase)) throw new TypeError(`phase: unsupported value ${phase}`); + if (!PLATFORMS.has(platform)) throw new TypeError(`platform: unsupported value ${platform}`); + const required = phase === 'render' ? INPUTS.render : INPUTS[phase][platform]; + const missing = required.filter(([, name]) => !env[name]); + if (missing.length > 0) { + const formatted = missing.map(([kind, name]) => `${kind} ${name}`).join(', '); + throw new TypeError( + `${phase}/${platform}: missing GitHub release environment inputs: ${formatted}`, + ); + } + if (phase === 'sign' && platform === 'desktop') { + let key; + try { + key = Buffer.from(env.APPLE_API_KEY_BASE64, 'base64').toString('utf8'); + } catch { + throw new TypeError('sign/desktop: secret APPLE_API_KEY_BASE64 must be valid base64'); + } + if (!key.includes('BEGIN PRIVATE KEY') || !key.includes('END PRIVATE KEY')) { + throw new TypeError( + 'sign/desktop: secret APPLE_API_KEY_BASE64 must encode an App Store Connect .p8 key', + ); + } + } + if (phase === 'upload' && platform === 'desktop' && !RE_R2_ACCOUNT_ID.test(env.R2_ACCOUNT_ID)) { + throw new TypeError( + 'upload/desktop: secret R2_ACCOUNT_ID must be a lowercase 32-hex Cloudflare account ID', + ); + } +} + +function runCli(argv = process.argv.slice(2), env = process.env) { + const { values } = require('node:util').parseArgs({ + args: argv, + options: { phase: { type: 'string' }, platform: { type: 'string' } }, + strict: true, + }); + if (!values.phase) throw new TypeError('--phase is required'); + if (!values.platform) throw new TypeError('--platform is required'); + validateReleaseInputs({ env, phase: values.phase, platform: values.platform }); + console.log(`validated ${values.phase}/${values.platform} release inputs`); +} + +if (require.main === module) runCli(); + +module.exports = { validateReleaseInputs }; diff --git a/.github/scripts/release-inputs.test.mjs b/.github/scripts/release-inputs.test.mjs new file mode 100644 index 00000000..cf476a8f --- /dev/null +++ b/.github/scripts/release-inputs.test.mjs @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import inputsModule from './release-inputs.cjs'; + +const { validateReleaseInputs } = inputsModule; +const RE_RENDER_MISSING = /var CONFIG_PUBLISHER_REPO.*secret CONFIG_PUBLISHER_TOKEN/; +const RE_MOBILE_SIGNING = + /secret EXPO_TOKEN.*secret POSTHOG_PROJECT_TOKEN.*var POSTHOG_HOST.*secret SENTRY_AUTH_TOKEN.*secret SENTRY_DSN_MOBILE/; +const RE_DESKTOP_UPLOAD = /R2_ACCESS_KEY_ID.*R2_ACCOUNT_ID.*R2_SECRET_ACCESS_KEY/; +const RE_INVALID_KEY = /must encode an App Store Connect \.p8 key/; +const RE_INVALID_ACCOUNT = /must be a lowercase 32-hex Cloudflare account ID/; + +describe('validateReleaseInputs', () => { + it('reports absent render vars and secrets by exact GitHub name', () => { + expect(() => validateReleaseInputs({ env: {}, phase: 'render', platform: 'desktop' })).toThrow( + RE_RENDER_MISSING, + ); + }); + + it('requires signing and upload inputs only for the requested platform', () => { + expect(() => validateReleaseInputs({ env: {}, phase: 'sign', platform: 'mobile' })).toThrow( + RE_MOBILE_SIGNING, + ); + expect(() => + validateReleaseInputs({ + env: { EXPO_TOKEN: 'non-production-test' }, + phase: 'upload', + platform: 'mobile', + }), + ).not.toThrow(); + expect(() => validateReleaseInputs({ env: {}, phase: 'upload', platform: 'desktop' })).toThrow( + RE_DESKTOP_UPLOAD, + ); + }); + + it('rejects malformed desktop notarization key material', () => { + const env = Object.fromEntries( + [ + 'APPLE_API_KEY_BASE64', + 'APPLE_API_KEY_ID', + 'APPLE_API_ISSUER', + 'APPLE_TEAM_ID', + 'AZURE_CERTIFICATE_PROFILE', + 'AZURE_CLIENT_ID', + 'AZURE_CODE_SIGNING_ACCOUNT', + 'AZURE_PUBLISHER_NAME', + 'AZURE_SIGN_ENDPOINT', + 'AZURE_TENANT_ID', + 'MACOS_CSC_KEY_PASSWORD', + 'MACOS_CSC_LINK', + 'POSTHOG_HOST', + 'POSTHOG_PROJECT_TOKEN', + 'SENTRY_DSN_DESKTOP', + ].map((name) => [name, 'set']), + ); + expect(() => validateReleaseInputs({ env, phase: 'sign', platform: 'desktop' })).toThrow( + RE_INVALID_KEY, + ); + }); + + it('rejects an R2 account value that could change the endpoint authority', () => { + expect(() => + validateReleaseInputs({ + env: { + R2_ACCESS_KEY_ID: 'set', + R2_ACCOUNT_ID: 'example.invalid/path?account=', + R2_SECRET_ACCESS_KEY: 'set', + }, + phase: 'upload', + platform: 'desktop', + }), + ).toThrow(RE_INVALID_ACCOUNT); + }); +}); From 153760d5436272b2860968e738c5911e1edaee20 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:55:26 +0000 Subject: [PATCH 11/20] feat(mobile): bind branded release destinations Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- apps/mobile/app.config.ts | 12 ++- .../src/build/__tests__/expo-brand.test.ts | 29 +++++++ apps/mobile/src/build/expo-brand.ts | 81 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ceecf0c3..9dfc22a0 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -12,8 +12,10 @@ import type { ConfigContext, ExpoConfig } from 'expo/config'; // stripping, Node >= 24) only when the extension is spelled out. import { applyBrandExpoConfig, + applyBrandReleaseConfig, deriveExpoBrandOverlay, parseExpoBrandOverlay, + parseExpoBrandReleaseConfig, serializeExpoBrandOverlay, } from './src/build/expo-brand.ts'; @@ -58,5 +60,13 @@ function loadGeneratedBrand(): ReturnType | null { export default ({ config }: ConfigContext): ExpoConfig => { const base = config as ExpoConfig; const overlay = loadGeneratedBrand(); - return overlay === null ? base : applyBrandExpoConfig(base, overlay); + const releasePath = join(__dirname, 'generated', 'mobile-release.json'); + if (overlay === null) { + if (existsSync(releasePath)) throw new Error('mobile-release.json requires a rendered brand'); + return base; + } + const branded = applyBrandExpoConfig(base, overlay); + if (!existsSync(releasePath)) return branded; + const release = parseExpoBrandReleaseConfig(JSON.parse(readFileSync(releasePath, 'utf8'))); + return applyBrandReleaseConfig(branded, overlay, release); }; diff --git a/apps/mobile/src/build/__tests__/expo-brand.test.ts b/apps/mobile/src/build/__tests__/expo-brand.test.ts index c8c45dbb..9cb8e891 100644 --- a/apps/mobile/src/build/__tests__/expo-brand.test.ts +++ b/apps/mobile/src/build/__tests__/expo-brand.test.ts @@ -4,8 +4,10 @@ import baseAppJson from '../../../app.json'; import type { ExpoBrandableConfig } from '../expo-brand'; import { applyBrandExpoConfig, + applyBrandReleaseConfig, deriveExpoBrandOverlay, parseExpoBrandOverlay, + parseExpoBrandReleaseConfig, serializeExpoBrandOverlay, } from '../expo-brand'; @@ -178,3 +180,30 @@ describe('applyBrandExpoConfig', () => { expect(applyBrandExpoConfig(BASE, ACME)).toStrictEqual(branded); }); }); + +describe('brand release config', () => { + const release = parseExpoBrandReleaseConfig({ + android: { track: 'internal' }, + brandId: 'acme', + channel: 'stable', + easProjectId: '11111111-1111-4111-8111-111111111111', + ios: { appleTeamId: 'ABC1234567', ascAppId: '1234567890' }, + mobileReleaseFormatVersion: 1, + updatesUrl: 'https://u.expo.dev/11111111-1111-4111-8111-111111111111', + }); + + it('injects only the brand-scoped EAS/update delivery binding', () => { + const branded = applyBrandReleaseConfig(applyBrandExpoConfig(BASE, ACME), ACME, release); + expect(branded.extra).toStrictEqual({ eas: { projectId: release.easProjectId } }); + expect(branded.updates?.url).toBe(release.updatesUrl); + expect(branded.ios?.appleTeamId).toBe(release.ios.appleTeamId); + }); + + it('rejects cross-brand bindings, unknown fields, and non-internal delivery', () => { + expect(() => applyBrandReleaseConfig(BASE, ZENITH, release)).toThrow(/does not match/); + expect(() => parseExpoBrandReleaseConfig({ ...release, executable: 'payload' })).toThrow(/exactly/); + expect(() => + parseExpoBrandReleaseConfig({ ...release, android: { track: 'production' } }), + ).toThrow(/must be internal/); + }); +}); diff --git a/apps/mobile/src/build/expo-brand.ts b/apps/mobile/src/build/expo-brand.ts index df192d0c..72f2575d 100644 --- a/apps/mobile/src/build/expo-brand.ts +++ b/apps/mobile/src/build/expo-brand.ts @@ -16,6 +16,16 @@ export interface ExpoBrandOverlay { readonly urlScheme: string; } +export interface ExpoBrandReleaseConfig { + readonly android: { readonly track: 'internal' }; + readonly brandId: string; + readonly channel: string; + readonly easProjectId: string; + readonly ios: { readonly appleTeamId: string; readonly ascAppId: string }; + readonly mobileReleaseFormatVersion: 1; + readonly updatesUrl: string; +} + /** Staged brand icon, relative to apps/mobile (where app.config.ts resolves asset paths). */ export const MOBILE_BRAND_ICON_PATH = './generated/brand-assets/icon.png'; @@ -105,6 +115,77 @@ export function parseExpoBrandOverlay(value: unknown): ExpoBrandOverlay { return record as unknown as ExpoBrandOverlay; } +const RELEASE_KEYS = [ + 'android', + 'brandId', + 'channel', + 'easProjectId', + 'ios', + 'mobileReleaseFormatVersion', + 'updatesUrl', +] as const; +const RE_EAS_PROJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const RE_APPLE_TEAM_ID = /^[A-Z0-9]{10}$/; +const RE_ASC_APP_ID = /^\d+$/; + +export function parseExpoBrandReleaseConfig(value: unknown): ExpoBrandReleaseConfig { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail('Expo brand release config must be a JSON object'); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== RELEASE_KEYS.length || keys.some((key, index) => key !== RELEASE_KEYS[index])) { + fail(`Expo brand release config must contain exactly: ${RELEASE_KEYS.join(', ')}`); + } + if (record.mobileReleaseFormatVersion !== 1) fail('Expo brand release config version must be 1'); + for (const field of ['brandId', 'channel', 'easProjectId', 'updatesUrl']) { + if (typeof record[field] !== 'string' || record[field] === '') fail(`Expo brand release ${field} is required`); + } + if (!RE_EAS_PROJECT_ID.test(record.easProjectId as string)) fail('Expo brand release easProjectId is invalid'); + if (record.updatesUrl !== `https://u.expo.dev/${record.easProjectId as string}`) { + fail('Expo brand release updatesUrl must match easProjectId'); + } + if (typeof record.ios !== 'object' || record.ios === null || Array.isArray(record.ios)) { + fail('Expo brand release ios is invalid'); + } + const ios = record.ios as Record; + if ( + Object.keys(ios).sort().join(',') !== 'appleTeamId,ascAppId' || + typeof ios.appleTeamId !== 'string' || + !RE_APPLE_TEAM_ID.test(ios.appleTeamId) || + typeof ios.ascAppId !== 'string' || + !RE_ASC_APP_ID.test(ios.ascAppId) + ) { + fail('Expo brand release ios identifiers are invalid'); + } + if ( + typeof record.android !== 'object' || + record.android === null || + Array.isArray(record.android) || + Object.keys(record.android).join(',') !== 'track' || + (record.android as { track?: unknown }).track !== 'internal' + ) { + fail('Expo brand release Android track must be internal'); + } + return record as unknown as ExpoBrandReleaseConfig; +} + +export function applyBrandReleaseConfig( + config: ExpoBrandableConfig, + overlay: ExpoBrandOverlay, + release: ExpoBrandReleaseConfig, +): ExpoBrandableConfig { + if (release.brandId !== overlay.brandId || release.channel !== overlay.channel) { + fail(`Expo brand release target ${release.brandId}/${release.channel} does not match ${overlay.brandId}/${overlay.channel}`); + } + return { + ...config, + extra: { ...config.extra, eas: { projectId: release.easProjectId } }, + ios: { ...config.ios, appleTeamId: release.ios.appleTeamId }, + updates: { ...config.updates, url: release.updatesUrl }, + }; +} + /** The default product name as it appears in user-facing template strings of the base config * (permission prompts). Only exact-case occurrences are rebranded; lowercase protocol/service * identifiers (`_linkcode._tcp`) are shared-core runtime contracts and stay untouched. */ From 7a4a333372846a0cf4e26b9b390c115d33aabdc5 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:55:55 +0000 Subject: [PATCH 12/20] feat(release): enforce store configuration compliance Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .../common/src/node/release-compliance.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/foundation/common/src/node/release-compliance.ts diff --git a/packages/foundation/common/src/node/release-compliance.ts b/packages/foundation/common/src/node/release-compliance.ts new file mode 100644 index 00000000..21c893b5 --- /dev/null +++ b/packages/foundation/common/src/node/release-compliance.ts @@ -0,0 +1,117 @@ +import type { ConfigBuildBundle } from '../config'; +import { configBuildBundleSnapshot } from '../config'; + +export interface StoreComplianceDeclaration { + readonly checklist: Readonly>; + readonly disclosedFeatures: readonly string[]; +} + +const RE_EXECUTABLE_STRING = + /^\s*(?:#!|javascript:|data:\s*(?:application|text)\/(?:ecmascript|javascript)|data:\s*application\/wasm)| Object.keys(override.set)), + ...Object.keys(snapshot.rollouts), + ]; +} + +function configurationKeyTokens(key: string): readonly string[] { + return key + .replaceAll(RE_CAMEL_CASE_BOUNDARY, '$1.$2') + .split(RE_KEY_SEGMENT_SPLIT) + .map((token) => token.toLowerCase()); +} + +function assertSafeConfigurationValue( + value: unknown, + path: string, + disclosedFeatures: ReadonlySet, +): void { + if (typeof value === 'string' && RE_EXECUTABLE_STRING.test(value)) { + throw new TypeError(`${path} looks like executable code or an executable-code URL`); + } + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) { + assertSafeConfigurationValue(entry, `${path}[${index}]`, disclosedFeatures); + } + return; + } + if (typeof value !== 'object' || value === null) return; + for (const [key, entry] of Object.entries(value)) { + const tokens = configurationKeyTokens(key); + if (tokens.some((token) => EXECUTABLE_KEY_TOKENS.has(token))) { + throw new TypeError(`${path}.${key} declares an executable-code surface`); + } + if (tokens.some((token) => token.startsWith('review')) && !disclosedFeatures.has(key)) { + throw new TypeError( + `review configuration key ${path}.${key} is not a disclosed feature/module`, + ); + } + assertSafeConfigurationValue(entry, `${path}.${key}`, disclosedFeatures); + } +} + +export function assertStoreCompliance( + bundle: ConfigBuildBundle, + declaration: StoreComplianceDeclaration, +): void { + const checklistKeys = Object.keys(declaration.checklist).sort(); + if ( + checklistKeys.length !== STORE_CHECKLIST_KEYS.length || + checklistKeys.some((key, index) => key !== STORE_CHECKLIST_KEYS[index]) + ) { + throw new TypeError( + `compliance checklist must contain exactly: ${STORE_CHECKLIST_KEYS.join(', ')}`, + ); + } + for (const key of STORE_CHECKLIST_KEYS) { + if (!declaration.checklist[key]) { + throw new TypeError(`compliance checklist ${key} must be true`); + } + } + const keys = [...new Set(configurationKeys(bundle))].sort(); + const configurableFeatures = keys.filter( + (key) => key.startsWith('feature.') || key.startsWith('modules.'), + ); + if (JSON.stringify(configurableFeatures) !== JSON.stringify(declaration.disclosedFeatures)) { + throw new TypeError( + `disclosedFeatures must exactly match configurable feature/module keys: ${configurableFeatures.join(', ')}`, + ); + } + for (const key of keys) { + if (configurationKeyTokens(key).some((token) => EXECUTABLE_KEY_TOKENS.has(token))) { + throw new TypeError(`configuration key ${key} declares an executable-code surface`); + } + } + assertSafeConfigurationValue( + configBuildBundleSnapshot(bundle), + 'snapshot', + new Set(configurableFeatures), + ); +} From 60ea0fe9bfd7e628668d6974a43e577e4e1047a8 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:56:05 +0000 Subject: [PATCH 13/20] feat(release): bind artifact provenance Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- packages/foundation/common/src/node/index.ts | 1 + .../common/src/node/release-artifact.ts | 364 ++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 packages/foundation/common/src/node/release-artifact.ts diff --git a/packages/foundation/common/src/node/index.ts b/packages/foundation/common/src/node/index.ts index 6c5526f0..fe36b43c 100644 --- a/packages/foundation/common/src/node/index.ts +++ b/packages/foundation/common/src/node/index.ts @@ -15,6 +15,7 @@ export * from './brand-assets'; export * from './config-brand-render'; export * from './config-build-render'; export { executableSearchLocations } from './executable-locations'; +export * from './release-artifact'; export * from './windows-path'; /** Parse a JSON file, or `null` when it is missing, unreadable, or malformed. */ diff --git a/packages/foundation/common/src/node/release-artifact.ts b/packages/foundation/common/src/node/release-artifact.ts new file mode 100644 index 00000000..b14cd042 --- /dev/null +++ b/packages/foundation/common/src/node/release-artifact.ts @@ -0,0 +1,364 @@ +/// +import { createHash } from 'node:crypto'; +import { lstat, readFile, realpath, writeFile } from 'node:fs/promises'; +import { dirname, relative, resolve } from 'node:path'; +import type { BrandIdentityArtifact, ConfigBuildBundle } from '../config'; +import { + assertBrandIdentityMatchesBundle, + canonicalizeJson, + configBuildBundleDefaults, + parseBrandIdentityArtifact, + parseConfigBuildBundle, +} from '../config'; +import type { JsonValue } from '../config/types'; +import type { StoreComplianceDeclaration } from './release-compliance'; +import { assertStoreCompliance } from './release-compliance'; + +export type { StoreComplianceDeclaration } from './release-compliance'; +export { assertStoreCompliance } from './release-compliance'; + +export interface ReleaseManifestBinding { + readonly brandId: string; + readonly channel: string; + readonly configRevisionId: string; + readonly expectedSnapshotSha256: string; + readonly platform: string; + readonly publisherGitSha: string; + readonly sourceGitSha: string; +} + +export interface ReleaseArtifactProvenance { + readonly artifacts: ReadonlyArray<{ + readonly brandManifestSha256: string; + readonly configRevisionId: string; + readonly defaultsSha256: string; + readonly path: string; + readonly sha256: string; + readonly sizeBytes: number; + }>; + readonly brandId: string; + readonly channel: string; + readonly clientGitSha: string; + readonly configSnapshotSha256: string; + readonly platform: string; + readonly publisherGitSha: string; + readonly releaseArtifactProvenanceVersion: 1; + readonly releaseManifestSha256: string; + readonly signed: boolean; + readonly sourceGitSha: string; +} + +const RE_GIT_SHA = /^[0-9a-f]{40}$/; +const RE_SHA256 = /^[0-9a-f]{64}$/; + +function sha256(bytes: string | Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +function assertReleaseBinding(bundle: ConfigBuildBundle, manifest: ReleaseManifestBinding): void { + const checks = [ + ['brandId', bundle.brandId, manifest.brandId], + ['channel', bundle.channel, manifest.channel], + ['platform', bundle.platform, manifest.platform], + ['sourceGitSha', bundle.provenance.sourceGitSha, manifest.sourceGitSha], + ['configRevisionId', bundle.provenance.configRevisionId, manifest.configRevisionId], + ['expectedSnapshotSha256', bundle.snapshot.sha256, manifest.expectedSnapshotSha256], + ] as const; + for (const [field, actual, expected] of checks) { + if (actual !== expected) { + throw new Error(`release manifest ${field} does not match the rendered bundle`); + } + } +} + +async function artifactFile( + root: string, + path: string, +): Promise<{ path: string; sha256: string; sizeBytes: number }> { + const absoluteRoot = await realpath(root); + const absolutePath = resolve(root, path); + const relativePath = relative(absoluteRoot, absolutePath); + if (relativePath === '' || relativePath.startsWith('..') || relativePath.includes('\\')) { + throw new TypeError(`artifact path escapes its isolated root: ${path}`); + } + const link = await lstat(absolutePath); + if (link.isSymbolicLink() || !link.isFile()) { + throw new TypeError(`artifact must be a regular file: ${path}`); + } + const canonicalPath = await realpath(absolutePath); + if (relative(absoluteRoot, canonicalPath).startsWith('..')) { + throw new TypeError(`artifact resolves outside its isolated root: ${path}`); + } + const bytes = await readFile(canonicalPath); + return { + path: relativePath.replaceAll('\\', '/'), + sha256: sha256(bytes), + sizeBytes: bytes.byteLength, + }; +} + +export async function createReleaseArtifactProvenance(input: { + readonly artifactPaths: readonly string[]; + readonly artifactRoot: string; + readonly brandIdentity: BrandIdentityArtifact; + readonly brandManifestBytes: Uint8Array; + readonly bundle: ConfigBuildBundle; + readonly clientGitSha: string; + readonly compliance: StoreComplianceDeclaration; + readonly releaseManifest: ReleaseManifestBinding; + readonly releaseManifestBytes: Uint8Array; + readonly signed: boolean; +}): Promise { + if ( + input.artifactPaths.length === 0 || + new Set(input.artifactPaths).size !== input.artifactPaths.length + ) { + throw new TypeError('artifactPaths must be non-empty and unique'); + } + if (!RE_GIT_SHA.test(input.clientGitSha)) { + throw new TypeError('clientGitSha must be an exact lowercase 40-hex commit'); + } + assertBrandIdentityMatchesBundle(input.brandIdentity, input.bundle); + assertReleaseBinding(input.bundle, input.releaseManifest); + assertStoreCompliance(input.bundle, input.compliance); + const defaults = jsonValue(configBuildBundleDefaults(input.bundle)); + const defaultsSha256 = sha256(canonicalizeJson(defaults)); + const brandManifestSha256 = sha256(input.brandManifestBytes); + const files = await Promise.all( + [...input.artifactPaths].sort().map((path) => artifactFile(input.artifactRoot, path)), + ); + return { + artifacts: files.map((file) => ({ + ...file, + brandManifestSha256, + configRevisionId: input.bundle.provenance.configRevisionId, + defaultsSha256, + })), + brandId: input.bundle.brandId, + channel: input.bundle.channel, + clientGitSha: input.clientGitSha, + configSnapshotSha256: input.bundle.snapshot.sha256, + platform: input.bundle.platform, + publisherGitSha: input.releaseManifest.publisherGitSha, + releaseArtifactProvenanceVersion: 1, + releaseManifestSha256: sha256(input.releaseManifestBytes), + signed: input.signed, + sourceGitSha: input.bundle.provenance.sourceGitSha, + }; +} + +function releaseArtifactProvenance(value: unknown): ReleaseArtifactProvenance { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError('release provenance must be an object'); + } + const provenance = value as Record; + const keys = Object.keys(provenance).sort(); + const expectedKeys = [ + 'artifacts', + 'brandId', + 'channel', + 'clientGitSha', + 'configSnapshotSha256', + 'platform', + 'publisherGitSha', + 'releaseArtifactProvenanceVersion', + 'releaseManifestSha256', + 'signed', + 'sourceGitSha', + ].sort(); + if ( + keys.length !== expectedKeys.length || + keys.some((key, index) => key !== expectedKeys[index]) + ) { + throw new TypeError(`release provenance must contain exactly: ${expectedKeys.join(', ')}`); + } + if ( + provenance.releaseArtifactProvenanceVersion !== 1 || + typeof provenance.brandId !== 'string' || + (provenance.channel !== 'canary' && provenance.channel !== 'stable') || + typeof provenance.platform !== 'string' || + typeof provenance.signed !== 'boolean' || + typeof provenance.clientGitSha !== 'string' || + !RE_GIT_SHA.test(provenance.clientGitSha) || + typeof provenance.configSnapshotSha256 !== 'string' || + !RE_SHA256.test(provenance.configSnapshotSha256) || + typeof provenance.releaseManifestSha256 !== 'string' || + !RE_SHA256.test(provenance.releaseManifestSha256) || + typeof provenance.publisherGitSha !== 'string' || + !RE_GIT_SHA.test(provenance.publisherGitSha) || + typeof provenance.sourceGitSha !== 'string' || + !RE_GIT_SHA.test(provenance.sourceGitSha) || + !Array.isArray(provenance.artifacts) || + provenance.artifacts.length === 0 + ) { + throw new TypeError('release provenance has invalid target, digest, or source fields'); + } + const paths = new Set(); + const artifacts: Array = []; + for (const [index, value] of provenance.artifacts.entries()) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new TypeError(`release provenance artifact ${index} must be an object`); + } + const artifact = value as Record; + const artifactKeys = Object.keys(artifact).sort(); + const expectedArtifactKeys = [ + 'brandManifestSha256', + 'configRevisionId', + 'defaultsSha256', + 'path', + 'sha256', + 'sizeBytes', + ].sort(); + if ( + artifactKeys.length !== expectedArtifactKeys.length || + artifactKeys.some((key, artifactIndex) => key !== expectedArtifactKeys[artifactIndex]) + ) { + throw new TypeError( + `release provenance artifact ${index} must contain exactly: ${expectedArtifactKeys.join(', ')}`, + ); + } + if ( + typeof artifact.path !== 'string' || + artifact.path === '' || + paths.has(artifact.path) || + typeof artifact.configRevisionId !== 'string' || + artifact.configRevisionId === '' || + typeof artifact.brandManifestSha256 !== 'string' || + !RE_SHA256.test(artifact.brandManifestSha256) || + typeof artifact.defaultsSha256 !== 'string' || + !RE_SHA256.test(artifact.defaultsSha256) || + typeof artifact.sha256 !== 'string' || + !RE_SHA256.test(artifact.sha256) || + typeof artifact.sizeBytes !== 'number' || + !Number.isSafeInteger(artifact.sizeBytes) || + artifact.sizeBytes < 0 + ) { + throw new TypeError(`release provenance artifact ${index} has invalid trace fields`); + } + paths.add(artifact.path); + artifacts.push({ + brandManifestSha256: artifact.brandManifestSha256, + configRevisionId: artifact.configRevisionId, + defaultsSha256: artifact.defaultsSha256, + path: artifact.path, + sha256: artifact.sha256, + sizeBytes: artifact.sizeBytes, + }); + } + return { + artifacts, + brandId: provenance.brandId, + channel: provenance.channel, + clientGitSha: provenance.clientGitSha, + configSnapshotSha256: provenance.configSnapshotSha256, + platform: provenance.platform, + publisherGitSha: provenance.publisherGitSha, + releaseArtifactProvenanceVersion: 1, + releaseManifestSha256: provenance.releaseManifestSha256, + signed: provenance.signed, + sourceGitSha: provenance.sourceGitSha, + }; +} + +export async function verifyReleaseArtifactProvenance(input: { + readonly artifactRoot: string; + readonly brandIdentity: BrandIdentityArtifact; + readonly brandManifestBytes: Uint8Array; + readonly brandId: string; + readonly bundle: ConfigBuildBundle; + readonly clientGitSha: string; + readonly platform: string; + readonly provenance: unknown; + readonly releaseManifest: ReleaseManifestBinding; + readonly releaseManifestBytes: Uint8Array; + readonly signed: boolean; +}): Promise { + const provenance = releaseArtifactProvenance(input.provenance); + if (!RE_GIT_SHA.test(input.clientGitSha)) { + throw new TypeError('clientGitSha must be an exact lowercase 40-hex commit'); + } + assertBrandIdentityMatchesBundle(input.brandIdentity, input.bundle); + assertReleaseBinding(input.bundle, input.releaseManifest); + const brandManifestSha256 = sha256(input.brandManifestBytes); + const defaultsSha256 = sha256( + canonicalizeJson(jsonValue(configBuildBundleDefaults(input.bundle))), + ); + if ( + provenance.brandId !== input.brandId || + provenance.channel !== input.bundle.channel || + provenance.platform !== input.platform || + provenance.signed !== input.signed || + provenance.clientGitSha !== input.clientGitSha || + provenance.configSnapshotSha256 !== input.bundle.snapshot.sha256 || + provenance.publisherGitSha !== input.releaseManifest.publisherGitSha || + provenance.releaseManifestSha256 !== sha256(input.releaseManifestBytes) || + provenance.sourceGitSha !== input.bundle.provenance.sourceGitSha + ) { + throw new Error('release provenance does not match the expected immutable release target'); + } + const [first] = provenance.artifacts; + await Promise.all( + provenance.artifacts.map(async (expected) => { + if ( + expected.brandManifestSha256 !== brandManifestSha256 || + expected.brandManifestSha256 !== first.brandManifestSha256 || + expected.configRevisionId !== input.bundle.provenance.configRevisionId || + expected.configRevisionId !== first.configRevisionId || + expected.defaultsSha256 !== defaultsSha256 || + expected.defaultsSha256 !== first.defaultsSha256 + ) { + throw new Error('release provenance artifacts do not share immutable trace bindings'); + } + const actual = await artifactFile(input.artifactRoot, expected.path); + if (actual.sha256 !== expected.sha256 || actual.sizeBytes !== expected.sizeBytes) { + throw new Error(`artifact bytes do not match release provenance: ${expected.path}`); + } + }), + ); + return provenance; +} + +export async function writeReleaseArtifactProvenance( + path: string, + provenance: ReleaseArtifactProvenance, + artifactRoot: string, +): Promise { + const absoluteRoot = await realpath(artifactRoot); + const absolutePath = resolve(artifactRoot, path); + const relativePath = relative(absoluteRoot, absolutePath); + if (relativePath === '' || relativePath.startsWith('..') || relativePath.includes('\\')) { + throw new TypeError(`provenance path escapes its isolated root: ${path}`); + } + const canonicalParent = await realpath(dirname(absolutePath)); + if (relative(absoluteRoot, canonicalParent).startsWith('..')) { + throw new TypeError(`provenance path resolves outside its isolated root: ${path}`); + } + const output = `${canonicalizeJson(jsonValue(provenance))}\n`; + await writeFile(absolutePath, output, { encoding: 'utf8', flag: 'wx' }); +} + +function jsonValue(value: unknown): JsonValue { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + typeof value === 'number' + ) { + return value; + } + if (Array.isArray(value)) return value.map(jsonValue); + if (typeof value !== 'object') { + throw new TypeError('release provenance must contain only JSON values'); + } + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsonValue(entry)])); +} + +export function parseReleaseArtifactInputs( + bundle: unknown, + identity: unknown, +): { + readonly bundle: ConfigBuildBundle; + readonly identity: BrandIdentityArtifact; +} { + return { bundle: parseConfigBuildBundle(bundle), identity: parseBrandIdentityArtifact(identity) }; +} From 4ef55172ade716221d7aff1848d501d990db6d64 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:56:24 +0000 Subject: [PATCH 14/20] feat(release): add provenance verification CLI Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .../common/src/node/release-artifact-cli.mts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 packages/foundation/common/src/node/release-artifact-cli.mts diff --git a/packages/foundation/common/src/node/release-artifact-cli.mts b/packages/foundation/common/src/node/release-artifact-cli.mts new file mode 100644 index 00000000..c3c519d2 --- /dev/null +++ b/packages/foundation/common/src/node/release-artifact-cli.mts @@ -0,0 +1,175 @@ +import { readFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import { extractErrorMessage } from 'foxts/extract-error-message'; +import type { ReleaseManifestBinding, StoreComplianceDeclaration } from './release-artifact'; +import { + createReleaseArtifactProvenance, + parseReleaseArtifactInputs, + verifyReleaseArtifactProvenance, + writeReleaseArtifactProvenance, +} from './release-artifact'; + +const USAGE = `Usage: release-artifact + --artifact-root --artifact [--artifact ...] + --bundle --brand-identity --brand-manifest + --release-manifest --compliance --client-git-sha --out [--signed] + release-artifact --artifact-root --verify + --bundle --brand-identity --brand-manifest --release-manifest + --client-git-sha --expected-brand --expected-platform [--signed]`; + +function bail(message: string): never { + throw new TypeError(`release-artifact: ${message}\n\n${USAGE}`); +} + +async function json(path: string, label: string): Promise<{ bytes: Buffer; value: unknown }> { + let bytes: Buffer; + try { + bytes = await readFile(path); + } catch { + bail(`${label} is missing or unreadable: ${path}`); + } + try { + return { bytes, value: JSON.parse(bytes.toString()) }; + } catch { + bail(`${label} is not valid JSON: ${path}`); + } +} + +async function bundle(path: string): Promise { + const text = await readFile(path, 'utf8'); + if (path.endsWith('.json')) return JSON.parse(text); + const start = text.indexOf('= { bundle:'); + const end = text.lastIndexOf('};'); + if (start === -1 || end <= start) bail(`generated bundle has an invalid module shape: ${path}`); + return JSON.parse(text.slice(start + 2, end + 1).replace('{ bundle:', '{ "bundle":')).bundle; +} + +function releaseManifest(value: unknown): ReleaseManifestBinding { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + bail('release manifest must be an object'); + } + const manifest = value as Record; + const field = (name: string): string => { + const result = manifest[name]; + if (typeof result !== 'string' || result === '') { + bail(`release manifest field ${name} is required`); + } + return result; + }; + return { + brandId: field('brandId'), + channel: field('channel'), + configRevisionId: field('configRevisionId'), + expectedSnapshotSha256: field('expectedSnapshotSha256'), + platform: field('platform'), + publisherGitSha: field('publisherGitSha'), + sourceGitSha: field('sourceGitSha'), + }; +} + +function compliance(value: unknown): StoreComplianceDeclaration { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + bail('compliance declaration must be an object'); + } + const declaration = value as Record; + if ( + typeof declaration.checklist !== 'object' || + declaration.checklist === null || + Array.isArray(declaration.checklist) || + !Array.isArray(declaration.disclosedFeatures) || + declaration.disclosedFeatures.some((entry) => typeof entry !== 'string') + ) { + bail('compliance declaration must contain checklist and disclosedFeatures'); + } + return { + checklist: Object.fromEntries( + Object.entries(declaration.checklist).map(([key, entry]) => { + if (typeof entry !== 'boolean') bail(`compliance checklist field ${key} must be boolean`); + return [key, entry]; + }), + ), + disclosedFeatures: declaration.disclosedFeatures.filter( + (entry): entry is string => typeof entry === 'string', + ), + }; +} + +async function main(): Promise { + const { values } = parseArgs({ + allowPositionals: false, + options: { + artifact: { type: 'string', multiple: true }, + 'artifact-root': { type: 'string' }, + 'brand-identity': { type: 'string' }, + 'brand-manifest': { type: 'string' }, + bundle: { type: 'string' }, + 'client-git-sha': { type: 'string' }, + compliance: { type: 'string' }, + 'expected-brand': { type: 'string' }, + 'expected-platform': { type: 'string' }, + out: { type: 'string' }, + 'release-manifest': { type: 'string' }, + signed: { type: 'boolean', default: false }, + verify: { type: 'string' }, + }, + strict: true, + }); + const required = (name: keyof typeof values): string => { + const value = values[name]; + if (typeof value !== 'string') bail(`--${name} is required`); + return value; + }; + const artifactRoot = required('artifact-root'); + if (values.verify !== undefined) { + const input = await json(values.verify, 'release provenance'); + const releaseInput = await json(required('release-manifest'), 'release manifest'); + const parsed = parseReleaseArtifactInputs( + await bundle(required('bundle')), + await json(required('brand-identity'), 'brand identity').then((result) => result.value), + ); + const provenance = await verifyReleaseArtifactProvenance({ + artifactRoot, + brandIdentity: parsed.identity, + brandManifestBytes: await readFile(required('brand-manifest')), + brandId: required('expected-brand'), + bundle: parsed.bundle, + clientGitSha: required('client-git-sha'), + platform: required('expected-platform'), + provenance: input.value, + releaseManifest: releaseManifest(releaseInput.value), + releaseManifestBytes: releaseInput.bytes, + signed: values.signed, + }); + process.stdout.write( + `verified ${provenance.artifacts.length} artifact(s) for ${provenance.brandId}/${provenance.platform}/${provenance.channel}\n`, + ); + return; + } + if (values.artifact === undefined) bail('--artifact is required at least once'); + const artifactPaths = values.artifact; + const identityInput = await json(required('brand-identity'), 'brand identity'); + const releaseInput = await json(required('release-manifest'), 'release manifest'); + const complianceInput = await json(required('compliance'), 'compliance declaration'); + const parsed = parseReleaseArtifactInputs(await bundle(required('bundle')), identityInput.value); + const provenance = await createReleaseArtifactProvenance({ + artifactPaths, + artifactRoot, + brandIdentity: parsed.identity, + brandManifestBytes: await readFile(required('brand-manifest')), + bundle: parsed.bundle, + clientGitSha: required('client-git-sha'), + compliance: compliance(complianceInput.value), + releaseManifest: releaseManifest(releaseInput.value), + releaseManifestBytes: releaseInput.bytes, + signed: values.signed, + }); + await writeReleaseArtifactProvenance(required('out'), provenance, artifactRoot); + process.stdout.write( + `wrote provenance for ${provenance.brandId}/${provenance.platform}/${provenance.channel}\n`, + ); +} + +main().catch((error: unknown) => { + process.stderr.write(`${extractErrorMessage(error)}\n`); + process.exitCode = 1; +}); From 16cb7aed3459d3e8c4a4d452773d658bc38d3d0a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:56:24 +0000 Subject: [PATCH 15/20] test(release): cover provenance isolation Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .../node/__tests__/release-artifact.test.ts | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 packages/foundation/common/src/node/__tests__/release-artifact.test.ts diff --git a/packages/foundation/common/src/node/__tests__/release-artifact.test.ts b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts new file mode 100644 index 00000000..f2aba0d7 --- /dev/null +++ b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts @@ -0,0 +1,330 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + canonicalizeJson, + configBuildBundleDefaults, + parseBrandIdentityArtifact, + parseConfigBuildBundle, +} from '../../config'; +import identityFixture from '../../config/__fixtures__/brand-identity-v1.json'; +import bundleFixture from '../../config/__fixtures__/build-bundle-v1.json'; +import { + assertStoreCompliance, + createReleaseArtifactProvenance, + verifyReleaseArtifactProvenance, + writeReleaseArtifactProvenance, +} from '../release-artifact'; + +const bundle = parseConfigBuildBundle(structuredClone(bundleFixture)); +const identity = parseBrandIdentityArtifact(structuredClone(identityFixture)); +const disclosedFeatures = [ + 'feature.aiAssist', + 'feature.newEditor', + 'modules.messaging.enabled', + 'modules.terminal.enabled', + 'modules.workspace.enabled', +]; +const compliance = { + checklist: { + configurableFeaturesDisclosed: true, + dataPracticesReviewed: true, + noExecutableCode: true, + permissionsReviewed: true, + storeMetadataReviewed: true, + }, + disclosedFeatures, +}; +const releaseManifest = { + brandId: bundle.brandId, + channel: bundle.channel, + configRevisionId: bundle.provenance.configRevisionId, + expectedSnapshotSha256: bundle.snapshot.sha256, + platform: bundle.platform, + publisherGitSha: 'a'.repeat(40), + sourceGitSha: bundle.provenance.sourceGitSha, +}; +const RE_SHA256 = /^[0-9a-f]{64}$/; +const RE_EXISTS = /EEXIST/; +const clientGitSha = 'f'.repeat(40); + +describe('release artifact provenance', () => { + it('binds each isolated artifact to the manifest, revision, and defaults digests', async () => { + const root = await mkdtemp(join(tmpdir(), 'release-artifact-')); + await writeFile(join(root, 'installer.zip'), 'artifact'); + const provenance = await createReleaseArtifactProvenance({ + artifactPaths: ['installer.zip'], + artifactRoot: root, + brandIdentity: identity, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + bundle, + clientGitSha, + compliance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: false, + }); + expect(provenance).toMatchObject({ + brandId: 'acme', + platform: 'desktop', + signed: false, + }); + expect(provenance.artifacts[0]).toMatchObject({ + configRevisionId: bundle.provenance.configRevisionId, + path: 'installer.zip', + sizeBytes: 8, + }); + expect(provenance.artifacts[0]?.brandManifestSha256).toMatch(RE_SHA256); + expect(provenance.artifacts[0]?.defaultsSha256).toMatch(RE_SHA256); + expect(provenance.artifacts[0]?.brandManifestSha256).toBe( + createHash('sha256').update('brands: [acme]').digest('hex'), + ); + expect(provenance.artifacts[0]?.defaultsSha256).toBe( + createHash('sha256') + .update(canonicalizeJson(configBuildBundleDefaults(bundle))) + .digest('hex'), + ); + }); + + it('rejects undisclosed feature keys and executable-code surfaces', () => { + expect(() => assertStoreCompliance(bundle, { ...compliance, disclosedFeatures: [] })).toThrow( + 'must exactly match', + ); + const executable = structuredClone(bundleFixture); + const snapshot = JSON.parse(Buffer.from(executable.snapshot.base64Url, 'base64url').toString()); + snapshot.values['content.home.banner'].url = 'https://example.invalid/payload.wasm'; + const bytes = Buffer.from(canonicalizeJson(snapshot)); + executable.snapshot.base64Url = bytes.toString('base64url'); + executable.snapshot.sha256 = createHash('sha256').update(bytes).digest('hex'); + executable.snapshot.sizeBytes = bytes.byteLength; + expect(() => assertStoreCompliance(parseConfigBuildBundle(executable), compliance)).toThrow( + 'executable code', + ); + + for (const [key, value, expected] of [ + ['content.pluginUrl', 'https://example.invalid/content', 'executable'], + ['modules.wasmLoader', false, 'must exactly match'], + ['content.scriptPath', '/content/banner', 'executable'], + ['content.inline', '
', 'executable'], + ['content.source', 'data:text/javascript,alert(1)', 'executable'], + ] as const) { + const bypass = structuredClone(bundleFixture); + const bypassSnapshot = JSON.parse( + Buffer.from(bypass.snapshot.base64Url, 'base64url').toString(), + ); + bypassSnapshot.values[key] = value; + bypassSnapshot.applyModes[key] = 'hot'; + const bypassBytes = Buffer.from(canonicalizeJson(bypassSnapshot)); + bypass.snapshot.base64Url = bypassBytes.toString('base64url'); + bypass.snapshot.sha256 = createHash('sha256').update(bypassBytes).digest('hex'); + bypass.snapshot.sizeBytes = bypassBytes.byteLength; + expect(() => assertStoreCompliance(parseConfigBuildBundle(bypass), compliance)).toThrow( + expected, + ); + } + }); + + it('requires the complete store-compliance checklist', () => { + expect(() => + assertStoreCompliance(bundle, { + checklist: { noExecutableCode: true }, + disclosedFeatures, + }), + ).toThrow('must contain exactly'); + expect(() => + assertStoreCompliance(bundle, { + ...compliance, + checklist: { ...compliance.checklist, permissionsReviewed: false }, + }), + ).toThrow('permissionsReviewed must be true'); + }); + + it('rejects a review-only configuration key outside the disclosure surface', () => { + const review = structuredClone(bundleFixture); + const snapshot = JSON.parse(Buffer.from(review.snapshot.base64Url, 'base64url').toString()); + snapshot.values['app.review.mode'] = true; + snapshot.applyModes['app.review.mode'] = 'hot'; + const bytes = Buffer.from(canonicalizeJson(snapshot)); + review.snapshot.base64Url = bytes.toString('base64url'); + review.snapshot.sha256 = createHash('sha256').update(bytes).digest('hex'); + review.snapshot.sizeBytes = bytes.byteLength; + expect(() => assertStoreCompliance(parseConfigBuildBundle(review), compliance)).toThrow( + 'is not a disclosed feature/module', + ); + + const camelCase = structuredClone(bundleFixture); + const camelCaseSnapshot = JSON.parse( + Buffer.from(camelCase.snapshot.base64Url, 'base64url').toString(), + ); + camelCaseSnapshot.values['app.reviewMode'] = true; + camelCaseSnapshot.applyModes['app.reviewMode'] = 'hot'; + const camelCaseBytes = Buffer.from(canonicalizeJson(camelCaseSnapshot)); + camelCase.snapshot.base64Url = camelCaseBytes.toString('base64url'); + camelCase.snapshot.sha256 = createHash('sha256').update(camelCaseBytes).digest('hex'); + camelCase.snapshot.sizeBytes = camelCaseBytes.byteLength; + expect(() => assertStoreCompliance(parseConfigBuildBundle(camelCase), compliance)).toThrow( + 'is not a disclosed feature/module', + ); + + const lowercase = structuredClone(bundleFixture); + const lowercaseSnapshot = JSON.parse( + Buffer.from(lowercase.snapshot.base64Url, 'base64url').toString(), + ); + lowercaseSnapshot.values['app.reviewmode'] = true; + lowercaseSnapshot.applyModes['app.reviewmode'] = 'hot'; + const lowercaseBytes = Buffer.from(canonicalizeJson(lowercaseSnapshot)); + lowercase.snapshot.base64Url = lowercaseBytes.toString('base64url'); + lowercase.snapshot.sha256 = createHash('sha256').update(lowercaseBytes).digest('hex'); + lowercase.snapshot.sizeBytes = lowercaseBytes.byteLength; + expect(() => assertStoreCompliance(parseConfigBuildBundle(lowercase), compliance)).toThrow( + 'is not a disclosed feature/module', + ); + + const hidden = structuredClone(bundleFixture); + const hiddenSnapshot = JSON.parse( + Buffer.from(hidden.snapshot.base64Url, 'base64url').toString(), + ); + hiddenSnapshot.reviewMode = true; + const hiddenBytes = Buffer.from(canonicalizeJson(hiddenSnapshot)); + hidden.snapshot.base64Url = hiddenBytes.toString('base64url'); + hidden.snapshot.sha256 = createHash('sha256').update(hiddenBytes).digest('hex'); + hidden.snapshot.sizeBytes = hiddenBytes.byteLength; + expect(() => assertStoreCompliance(parseConfigBuildBundle(hidden), compliance)).toThrow( + 'is not a disclosed feature/module', + ); + }); + + it('rejects path traversal and mismatched release bindings without touching another brand', async () => { + const root = await mkdtemp(join(tmpdir(), 'release-isolation-')); + await writeFile(join(root, 'artifact'), 'acme'); + const otherEvidence = join(root, 'zenith.provenance.json'); + await writeFile(otherEvidence, 'untouched'); + const input = { + artifactPaths: ['../artifact'], + artifactRoot: root, + brandIdentity: identity, + brandManifestBytes: new Uint8Array(), + bundle, + clientGitSha, + compliance, + releaseManifest, + releaseManifestBytes: new Uint8Array(), + signed: false, + }; + await expect(createReleaseArtifactProvenance(input)).rejects.toThrow( + 'escapes its isolated root', + ); + await expect( + createReleaseArtifactProvenance({ + ...input, + artifactPaths: ['artifact'], + releaseManifest: { ...releaseManifest, configRevisionId: 'wrong' }, + }), + ).rejects.toThrow('configRevisionId does not match'); + expect(await readFile(otherEvidence, 'utf8')).toBe('untouched'); + }); + + it('writes evidence once and never overwrites prior provenance', async () => { + const root = await mkdtemp(join(tmpdir(), 'release-evidence-')); + const provenance = { + artifacts: [], + brandId: 'acme', + channel: 'canary', + clientGitSha, + configSnapshotSha256: 'a'.repeat(64), + platform: 'ios', + publisherGitSha: 'b'.repeat(40), + releaseArtifactProvenanceVersion: 1, + releaseManifestSha256: 'c'.repeat(64), + signed: false, + sourceGitSha: 'd'.repeat(40), + } as const; + await writeReleaseArtifactProvenance('provenance.json', provenance, root); + await expect( + writeReleaseArtifactProvenance('provenance.json', provenance, root), + ).rejects.toThrow(RE_EXISTS); + await expect(writeReleaseArtifactProvenance('../other.json', provenance, root)).rejects.toThrow( + 'escapes its isolated root', + ); + }); + + it('re-hashes artifacts before upload and rejects target or byte drift', async () => { + const root = await mkdtemp(join(tmpdir(), 'release-verify-')); + await writeFile(join(root, 'installer.zip'), 'artifact'); + const provenance = await createReleaseArtifactProvenance({ + artifactPaths: ['installer.zip'], + artifactRoot: root, + brandIdentity: identity, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + bundle, + clientGitSha, + compliance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: true, + }); + await expect( + verifyReleaseArtifactProvenance({ + artifactRoot: root, + brandIdentity: identity, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + brandId: bundle.brandId, + bundle, + clientGitSha, + platform: bundle.platform, + provenance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: true, + }), + ).resolves.toStrictEqual(provenance); + await expect( + verifyReleaseArtifactProvenance({ + artifactRoot: root, + brandIdentity: identity, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + brandId: 'zenith', + bundle, + clientGitSha, + platform: bundle.platform, + provenance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: true, + }), + ).rejects.toThrow('expected immutable release target'); + await expect( + verifyReleaseArtifactProvenance({ + artifactRoot: root, + brandIdentity: { ...identity, brandId: 'zenith' }, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + brandId: bundle.brandId, + bundle, + clientGitSha, + platform: bundle.platform, + provenance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: true, + }), + ).rejects.toThrow('brand identity targets zenith'); + await writeFile(join(root, 'installer.zip'), 'tampered'); + await expect( + verifyReleaseArtifactProvenance({ + artifactRoot: root, + brandIdentity: identity, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + brandId: bundle.brandId, + bundle, + clientGitSha, + platform: bundle.platform, + provenance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: true, + }), + ).rejects.toThrow('bytes do not match'); + }); +}); From 7bcc36e823ebfd54fbeca264a4e637d30b0fecda Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:56:50 +0000 Subject: [PATCH 16/20] feat(release): consume rendered brand artifacts Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .../actions/render-release-config/action.yml | 16 +++- .github/workflows/build-desktop.yml | 76 ++++++++++++++++--- .github/workflows/build-mobile.yml | 65 +++++++++++++--- 3 files changed, 136 insertions(+), 21 deletions(-) diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index e0d7c166..171ef99e 100644 --- a/.github/actions/render-release-config/action.yml +++ b/.github/actions/render-release-config/action.yml @@ -25,6 +25,10 @@ inputs: description: Public keyrings JSON content (vars.CONFIG_RELEASE_KEYRINGS) required: false default: "" + brand-artifacts: + description: Render the desktop brand identity, assets, and builder overlay + required: false + default: "false" release-manifest: description: Desktop release-render manifest JSON content (app == desktop) required: false @@ -52,6 +56,7 @@ runs: MANIFEST_DESKTOP: ${{ inputs.release-manifest }} MANIFEST_IOS: ${{ inputs.release-manifest-ios }} MANIFEST_ANDROID: ${{ inputs.release-manifest-android }} + BRAND_ARTIFACTS: ${{ inputs.brand-artifacts }} run: | set -euo pipefail @@ -73,7 +78,7 @@ runs: exit 1 fi - work="$RUNNER_TEMP/config-render" + work="$RUNNER_TEMP/config-render-$APP" mkdir -p "$work" printf '%s' "$REVISION_JSON" > "$work/revision.json" printf '%s' "$KEYRINGS_JSON" > "$work/keyrings.json" @@ -144,8 +149,15 @@ runs: --telemetry-endpoint "$telemetry" ) if [ "$APP" = desktop ]; then + brand_args=() + if [ "$BRAND_ARTIFACTS" = true ]; then + brand_args=(--brand-artifacts) + elif [ "$BRAND_ARTIFACTS" != false ]; then + echo "::error::brand-artifacts must be true or false" + exit 1 + fi pnpm -F @linkcode/desktop config:render "${common_args[@]}" \ - --release-manifest "$work/manifest-desktop.json" + --release-manifest "$work/manifest-desktop.json" "${brand_args[@]}" else pnpm -F @linkcode/mobile config:render "${common_args[@]}" \ --release-manifest-ios "$work/manifest-ios.json" \ diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 111b4e0d..9aa5ac1e 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -20,6 +20,21 @@ on: type: boolean required: false default: false + brand_id: + description: Brand id for isolated artifact names; empty keeps the default release flow + type: string + required: false + default: "" + rendered_artifact: + description: Pre-rendered brand/config artifact from the matrix workflow + type: string + required: false + default: "" + update_url: + description: Validated brand-scoped desktop update URL + type: string + required: false + default: "" # CI builds on PRs — unsigned. # pull_request: # paths: @@ -36,7 +51,7 @@ on: default: false concurrency: - group: build-desktop-${{ github.ref }}-${{ github.event_name }} + group: build-desktop-${{ github.ref }}-${{ github.event_name }}-${{ inputs.brand_id || 'linkcode' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: @@ -57,7 +72,7 @@ env: # Signed builds must embed the rendered immutable config bundle: the Vite main build and # verify-artifacts both fail when it is absent instead of shipping empty defaults. - LINKCODE_REQUIRE_CONFIG_BUNDLE: ${{ inputs.sign && '1' || '' }} + LINKCODE_REQUIRE_CONFIG_BUNDLE: ${{ (inputs.sign || inputs.rendered_artifact != '') && '1' || '' }} jobs: # Renders the immutable config bundle from the pinned config publisher checkout (release @@ -65,7 +80,7 @@ jobs: # build without a bundle; signed builds hard-require its output. render-config: name: Render immutable config - if: ${{ inputs.sign }} + if: ${{ inputs.sign && inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release steps: @@ -164,11 +179,25 @@ jobs: # The exact bytes rendered by render-config: Vite validates them, derives the inlined # bootstrap, and stages them into the asar; verify-artifacts byte-compares the staged copy. - name: Fetch rendered config bundle - if: ${{ inputs.sign }} + if: ${{ inputs.sign || inputs.rendered_artifact != '' }} uses: actions/download-artifact@v8 with: - name: desktop-config-bundle - path: apps/desktop/generated + name: ${{ inputs.rendered_artifact || 'desktop-config-bundle' }} + path: ${{ inputs.rendered_artifact != '' && '.' || 'apps/desktop/generated' }} + + - name: Validate branded packaging inputs + if: ${{ inputs.rendered_artifact != '' }} + shell: bash + env: + BRAND_ID: ${{ inputs.brand_id }} + BRAND_UPDATE_URL: ${{ inputs.update_url }} + run: | + set -euo pipefail + if [[ ! "$BRAND_ID" =~ ^[a-z][a-z0-9-]{0,62}$ ]]; then + echo "::error::brand_id must be a lowercase brand identifier" + exit 1 + fi + node -e 'const url = new URL(process.env.BRAND_UPDATE_URL); if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("update_url must be HTTPS without credentials, query, or fragment")' - parallel: - name: Build workspace @@ -178,7 +207,7 @@ jobs: # stay declared in apps/desktop/turbo.json `build.env` so Turbo cache keys include them. MAIN_VITE_SENTRY_DSN: ${{ inputs.sign && secrets.SENTRY_DSN_DESKTOP || '' }} RENDERER_VITE_POSTHOG_PROJECT_TOKEN: ${{ inputs.sign && secrets.POSTHOG_PROJECT_TOKEN || '' }} - RENDERER_VITE_POSTHOG_HOST: ${{ inputs.sign && secrets.POSTHOG_HOST || '' }} + RENDERER_VITE_POSTHOG_HOST: ${{ inputs.sign && vars.POSTHOG_HOST || '' }} # The PTY sidecar ships per arch under Resources (extraResources: sidecar/${arch}). - name: Build PTY sidecar (both arches) @@ -216,18 +245,24 @@ jobs: if [ -n "$MACOS_CSC_LINK" ]; then export CSC_LINK="$MACOS_CSC_LINK" CSC_KEY_PASSWORD="$MACOS_CSC_KEY_PASSWORD" fi + publish_args=() + if [ -n "$BRAND_UPDATE_URL" ]; then + publish_args=(-c.publish.provider=generic "-c.publish.url=$BRAND_UPDATE_URL" -c.publish.useMultipleRangeRequest=false) + fi if [ "${{ matrix.platform }}" = linux ]; then # Electron 43 needs Clang 15; the arm64 rebuild also needs an explicit cross target. CC=clang-15 CXX=clang++-15 \ - node scripts/package-app.mts linux --x64 --publish never + node scripts/package-app.mts linux --x64 --publish never "${publish_args[@]}" CC='clang-15 --target=aarch64-linux-gnu' \ CXX='clang++-15 --target=aarch64-linux-gnu' \ - node scripts/package-app.mts linux --arm64 --publish never + node scripts/package-app.mts linux --arm64 --publish never "${publish_args[@]}" else node scripts/package-app.mts ${{ matrix.platform }} --publish never \ + "${publish_args[@]}" \ ${{ (runner.os == 'Windows' && inputs.sign) && format('-c.win.azureSignOptions.publisherName="{0}" -c.win.azureSignOptions.endpoint="{1}" -c.win.azureSignOptions.codeSigningAccountName="{2}" -c.win.azureSignOptions.certificateProfileName="{3}"', env.AZURE_PUBLISHER_NAME, env.AZURE_SIGN_ENDPOINT, env.AZURE_CODE_SIGNING_ACCOUNT, env.AZURE_CERTIFICATE_PROFILE) || '' }} fi env: + BRAND_UPDATE_URL: ${{ inputs.update_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # macOS signing (Developer ID cert) + notarization (App Store Connect API key). @@ -252,10 +287,30 @@ jobs: shell: bash run: node scripts/verify-artifacts.mts ${{ matrix.platform }} + - name: Write artifact provenance + if: ${{ inputs.rendered_artifact != '' }} + shell: bash + run: | + set -euo pipefail + artifacts=() + while IFS= read -r -d '' path; do + artifacts+=(--artifact "$(basename "$path")") + done < <(find "${OUTPUT_DIR}" -maxdepth 1 -type f ! -name builder-debug.yml -print0 | sort -z) + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "${OUTPUT_DIR}" "${artifacts[@]}" \ + --bundle apps/desktop/generated/config-build-bundle.json \ + --brand-identity apps/desktop/generated/brand-identity.json \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --release-manifest release-inputs/release-manifest.desktop.json \ + --compliance release-inputs/compliance.desktop.json \ + --out "release-provenance.${{ matrix.platform }}.json" \ + ${{ inputs.sign && '--signed' || '' }} + - name: Upload artifacts uses: actions/upload-artifact@v7 with: - name: desktop-${{ matrix.platform }} + name: ${{ inputs.brand_id != '' && format('desktop-{0}-{1}', inputs.brand_id, matrix.platform) || format('desktop-{0}', matrix.platform) }} if-no-files-found: error retention-days: 7 # *.yml + *.blockmap are the electron-updater feed — do not drop them. builder-debug.yml @@ -272,4 +327,5 @@ jobs: ${{ env.OUTPUT_DIR }}/*.snap ${{ env.OUTPUT_DIR }}/*.yml ${{ env.OUTPUT_DIR }}/*.blockmap + ${{ env.OUTPUT_DIR }}/release-provenance.${{ matrix.platform }}.json !${{ env.OUTPUT_DIR }}/builder-debug.yml diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 5814db38..1ad5f5f7 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -4,6 +4,28 @@ name: Build Mobile on: + workflow_call: + inputs: + ref: + description: Git ref to build + type: string + required: false + default: "" + brand_id: + description: Brand id for isolated artifact names + type: string + required: false + default: "" + rendered_artifact: + description: Pre-rendered brand/config artifact from the matrix workflow + type: string + required: false + default: "" + submit: + description: Upload to TestFlight and Google Play internal testing + type: boolean + required: false + default: false workflow_dispatch: inputs: submit: @@ -13,7 +35,7 @@ on: default: false concurrency: - group: build-mobile-production + group: build-mobile-production-${{ inputs.brand_id || 'linkcode' }} cancel-in-progress: false permissions: @@ -31,9 +53,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref || github.ref }} - name: Check submit configuration - if: ${{ inputs.submit }} + if: ${{ inputs.submit && inputs.rendered_artifact == '' }} run: | asc_app_id="$(jq -r '.submit.production.ios.ascAppId // empty' apps/mobile/eas.json)" if [ -z "$asc_app_id" ]; then @@ -47,12 +71,15 @@ jobs: render-config: name: Render immutable config needs: preflight + if: ${{ inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} timeout-minutes: 20 environment: release steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup EAS uses: ./.github/actions/setup-eas @@ -82,6 +109,7 @@ jobs: build: name: Build ${{ matrix.platform }} needs: [preflight, render-config] + if: ${{ !cancelled() && needs.preflight.result == 'success' && (needs.render-config.result == 'success' || needs.render-config.result == 'skipped') }} runs-on: ${{ matrix.os }} timeout-minutes: 120 environment: release @@ -120,6 +148,8 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup EAS uses: ./.github/actions/setup-eas @@ -161,8 +191,8 @@ jobs: - name: Fetch generated config modules uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: mobile-config-modules - path: apps/mobile/src/runtime/config + name: ${{ inputs.rendered_artifact || 'mobile-config-modules' }} + path: ${{ inputs.rendered_artifact != '' && '.' || 'apps/mobile/src/runtime/config' }} - name: Verify release config modules run: pnpm -F @linkcode/mobile config:verify-release @@ -177,11 +207,28 @@ jobs: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: eas build --local --platform "${{ matrix.platform }}" --profile production --output "$RUNNER_TEMP/linkcode-${{ matrix.platform }}.${{ matrix.extension }}" --non-interactive + - name: Write artifact provenance + if: ${{ inputs.rendered_artifact != '' }} + run: | + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "$RUNNER_TEMP" \ + --artifact "linkcode-${{ matrix.platform }}.${{ matrix.extension }}" \ + --bundle "apps/mobile/src/runtime/config/bundled.generated.${{ matrix.platform }}.ts" \ + --brand-identity "apps/mobile/generated/brand-identity.${{ matrix.platform }}.json" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --release-manifest "release-inputs/release-manifest.${{ matrix.platform }}.json" \ + --compliance "release-inputs/compliance.${{ matrix.platform }}.json" \ + --out "release-provenance.${{ matrix.platform }}.json" \ + --signed + - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: mobile-${{ matrix.platform }} - path: ${{ runner.temp }}/linkcode-${{ matrix.platform }}.${{ matrix.extension }} + name: ${{ inputs.brand_id != '' && format('mobile-{0}-{1}', inputs.brand_id, matrix.platform) || format('mobile-{0}', matrix.platform) }} + path: | + ${{ runner.temp }}/linkcode-${{ matrix.platform }}.${{ matrix.extension }} + ${{ runner.temp }}/release-provenance.${{ matrix.platform }}.json if-no-files-found: error retention-days: 7 @@ -196,14 +243,14 @@ jobs: fail-fast: false matrix: include: - # - platform: android - # extension: aab - platform: ios extension: ipa steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref || github.ref }} - name: Setup EAS uses: ./.github/actions/setup-eas @@ -214,7 +261,7 @@ jobs: - name: Download artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: mobile-${{ matrix.platform }} + name: ${{ inputs.brand_id != '' && format('mobile-{0}-{1}', inputs.brand_id, matrix.platform) || format('mobile-{0}', matrix.platform) }} path: ${{ runner.temp }} - name: Submit artifact From e70f084b7b99595375d82047fd1ba1dd2020f314 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:58:24 +0000 Subject: [PATCH 17/20] feat(release): build isolated brand matrix Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/workflows/release-brand-matrix.yml | 333 +++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 .github/workflows/release-brand-matrix.yml diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml new file mode 100644 index 00000000..021de2a8 --- /dev/null +++ b/.github/workflows/release-brand-matrix.yml @@ -0,0 +1,333 @@ +name: Release Brand Matrix + +on: + workflow_dispatch: + inputs: + ref: + description: Exact client ref to build + type: string + required: true + matrix_json: + description: Matrix JSON; empty reads vars.BRAND_BUILD_MATRIX + type: string + required: false + default: "" + build: + description: Render and build every brand/platform target + type: boolean + required: true + default: false + sign: + description: Sign/notarize desktop and mobile artifacts + type: boolean + required: true + default: false + upload: + description: Upload only after every signed artifact and provenance gate succeeds + type: boolean + required: true + default: false + +concurrency: + group: release-brand-matrix-${{ inputs.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + prepare: + name: Validate matrix + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + outputs: + brands: ${{ steps.matrix.outputs.brands }} + targets: ${{ steps.matrix.outputs.targets }} + steps: + - name: Validate request shape + env: + CLIENT_REF: ${{ inputs.ref }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + set -euo pipefail + if [[ ! "$CLIENT_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::ref must be an exact lowercase 40-hex client commit" + exit 1 + fi + if [ "$CLIENT_REF" != "$WORKFLOW_SHA" ]; then + echo "::error::ref must equal github.sha so workflow code, release-environment policy, and built client use one commit" + exit 1 + fi + if ${{ (inputs.sign && !inputs.build) || (inputs.upload && !inputs.sign) || (inputs.matrix_json != '' && inputs.build) }}; then + echo "::error::sign requires build=true; upload requires sign=true; matrix_json is plan-only and build requests must use the reviewed BRAND_BUILD_MATRIX variable" + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + + - name: Verify exact client checkout + env: + CLIENT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$CLIENT_REF" + + - name: Build strict matrix plan + id: matrix + env: + BRAND_BUILD_MATRIX: ${{ inputs.matrix_json || vars.BRAND_BUILD_MATRIX }} + run: node .github/scripts/brand-matrix.cjs --build "${{ inputs.build }}" --sign "${{ inputs.sign }}" --upload "${{ inputs.upload }}" + + render-inputs: + name: Validate immutable render inputs + if: ${{ inputs.build }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_PUBLISHER_TOKEN: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + CONFIG_RELEASE_KEYRINGS: ${{ vars.CONFIG_RELEASE_KEYRINGS }} + CONFIG_RELEASE_REVISION: ${{ vars.CONFIG_RELEASE_REVISION }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - run: node .github/scripts/release-inputs.cjs --phase render --platform desktop + + signing-inputs: + name: Validate signing inputs + if: ${{ inputs.sign }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + env: + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + AZURE_CERTIFICATE_PROFILE: ${{ secrets.AZURE_CERTIFICATE_PROFILE }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CODE_SIGNING_ACCOUNT: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT }} + AZURE_PUBLISHER_NAME: ${{ secrets.AZURE_PUBLISHER_NAME }} + AZURE_SIGN_ENDPOINT: ${{ secrets.AZURE_SIGN_ENDPOINT }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + MACOS_CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }} + MACOS_CSC_LINK: ${{ secrets.MACOS_CSC_LINK }} + POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} + POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} + SENTRY_DSN_MOBILE: ${{ secrets.SENTRY_DSN_MOBILE }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - run: node .github/scripts/release-inputs.cjs --phase sign --platform desktop + - run: node .github/scripts/release-inputs.cjs --phase sign --platform mobile + - if: ${{ inputs.upload }} + run: node .github/scripts/release-inputs.cjs --phase upload --platform mobile + + render: + name: Render ${{ matrix.brandId }} + if: ${{ inputs.build }} + needs: [prepare, render-inputs] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: true + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + + - run: pnpm install --frozen-lockfile + + - name: Render desktop bundle and identity + uses: ./.github/actions/render-release-config + with: + app: desktop + brand-artifacts: true + publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} + publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + revision: ${{ vars.CONFIG_RELEASE_REVISION }} + keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} + release-manifest: ${{ toJSON(matrix.releaseManifests.desktop) }} + + - name: Render mobile bundles and identities + uses: ./.github/actions/render-release-config + with: + app: mobile + publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} + publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + revision: ${{ vars.CONFIG_RELEASE_REVISION }} + keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} + release-manifest-ios: ${{ toJSON(matrix.releaseManifests.ios) }} + release-manifest-android: ${{ toJSON(matrix.releaseManifests.android) }} + + - name: Stage immutable release inputs + env: + COMPLIANCE_ANDROID: ${{ toJSON(matrix.compliance.android) }} + COMPLIANCE_DESKTOP: ${{ toJSON(matrix.compliance.desktop) }} + COMPLIANCE_IOS: ${{ toJSON(matrix.compliance.ios) }} + MANIFEST_ANDROID: ${{ toJSON(matrix.releaseManifests.android) }} + MANIFEST_DESKTOP: ${{ toJSON(matrix.releaseManifests.desktop) }} + MANIFEST_IOS: ${{ toJSON(matrix.releaseManifests.ios) }} + MOBILE_DISTRIBUTION: ${{ toJSON(matrix.distribution.mobile) }} + run: | + set -euo pipefail + mkdir release-inputs + cp "$RUNNER_TEMP/config-render-desktop/source/packages/config-structural/brands.manifest.yaml" release-inputs/ + printf '%s' "$MANIFEST_DESKTOP" > release-inputs/release-manifest.desktop.json + printf '%s' "$MANIFEST_IOS" > release-inputs/release-manifest.ios.json + printf '%s' "$MANIFEST_ANDROID" > release-inputs/release-manifest.android.json + printf '%s' "$COMPLIANCE_DESKTOP" > release-inputs/compliance.desktop.json + printf '%s' "$COMPLIANCE_IOS" > release-inputs/compliance.ios.json + printf '%s' "$COMPLIANCE_ANDROID" > release-inputs/compliance.android.json + if [ "$MOBILE_DISTRIBUTION" != null ]; then + jq -cn \ + --arg brand '${{ matrix.brandId }}' \ + --arg channel '${{ matrix.channel }}' \ + --argjson distribution "$MOBILE_DISTRIBUTION" \ + '$distribution + {brandId: $brand, channel: $channel, mobileReleaseFormatVersion: 1}' \ + > apps/mobile/generated/mobile-release.json + cp apps/mobile/generated/mobile-release.json release-inputs/ + fi + + - name: Gate rendered defaults and store compliance + run: | + set -euo pipefail + mkdir release-inputs/preflight + for platform in desktop ios android; do + printf 'validated=%s/%s\n' '${{ matrix.brandId }}' "$platform" \ + > "release-inputs/preflight/${platform}.txt" + if [ "$platform" = desktop ]; then + bundle=apps/desktop/generated/config-build-bundle.json + identity=apps/desktop/generated/brand-identity.json + else + bundle="apps/mobile/src/runtime/config/bundled.generated.${platform}.ts" + identity="apps/mobile/generated/brand-identity.${platform}.json" + fi + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root release-inputs/preflight \ + --artifact "${platform}.txt" \ + --bundle "$bundle" \ + --brand-identity "$identity" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --release-manifest "release-inputs/release-manifest.${platform}.json" \ + --compliance "release-inputs/compliance.${platform}.json" \ + --out "${platform}.provenance.json" + done + + - name: Upload isolated rendered inputs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: brand-render-${{ matrix.brandId }} + path: | + apps/desktop/generated + apps/mobile/generated + apps/mobile/src/runtime/config/bundled.generated.ios.ts + apps/mobile/src/runtime/config/bundled.generated.android.ts + release-inputs + if-no-files-found: error + retention-days: 1 + + desktop: + name: Desktop ${{ matrix.brandId }} + if: ${{ inputs.build && !cancelled() && needs.render.result == 'success' && (needs.signing-inputs.result == 'success' || needs.signing-inputs.result == 'skipped') }} + needs: [prepare, render, signing-inputs] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + uses: ./.github/workflows/build-desktop.yml + secrets: inherit + permissions: + contents: read + id-token: write + with: + ref: ${{ inputs.ref }} + sign: ${{ inputs.sign }} + brand_id: ${{ matrix.brandId }} + rendered_artifact: brand-render-${{ matrix.brandId }} + update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} + + mobile-validation: + name: Mobile validation ${{ matrix.brandId }} + if: ${{ inputs.build && !inputs.sign && !cancelled() && needs.render.result == 'success' }} + needs: [prepare, render] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: ./.github/actions/setup-eas + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - name: Verify production Hermes exports + run: pnpm -F @linkcode/mobile smoke:export + - name: Verify credential-free native generation + working-directory: apps/mobile + run: | + CI=1 EXPO_NO_TELEMETRY=1 pnpm exec expo prebuild --clean --no-install --platform android + test -f android/app/build.gradle + rm -rf android + CI=1 EXPO_NO_TELEMETRY=1 pnpm exec expo prebuild --clean --no-install --platform ios + test -f ios/Podfile + - name: Record isolated validation evidence + run: | + mkdir -p "release-validation/${{ matrix.brandId }}" + for platform in ios android; do + printf '%s=production-hermes+prebuild\n' "$platform" \ + > "release-validation/${{ matrix.brandId }}/validation.${platform}.txt" + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "release-validation/${{ matrix.brandId }}" \ + --artifact "validation.${platform}.txt" \ + --bundle "apps/mobile/src/runtime/config/bundled.generated.${platform}.ts" \ + --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --release-manifest "release-inputs/release-manifest.${platform}.json" \ + --compliance "release-inputs/compliance.${platform}.json" \ + --out "release-provenance.${platform}.json" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: brand-validation-${{ matrix.brandId }} + path: release-validation/${{ matrix.brandId }} + if-no-files-found: error + retention-days: 7 + + mobile: + name: Mobile ${{ matrix.brandId }} + if: ${{ inputs.sign && !cancelled() && needs.render.result == 'success' && needs.signing-inputs.result == 'success' }} + needs: [prepare, render, signing-inputs] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + uses: ./.github/workflows/build-mobile.yml + secrets: inherit + with: + ref: ${{ inputs.ref }} + brand_id: ${{ matrix.brandId }} + rendered_artifact: brand-render-${{ matrix.brandId }} + submit: false From 567ca6c10258f2fead5e7dc25b4b1e9750289cb0 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:58:35 +0000 Subject: [PATCH 18/20] feat(release): gate matrix publication Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/workflows/release-brand-matrix.yml | 218 +++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 021de2a8..b77e6f58 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -331,3 +331,221 @@ jobs: brand_id: ${{ matrix.brandId }} rendered_artifact: brand-render-${{ matrix.brandId }} submit: false + + publish-preflight: + name: Publish preflight ${{ matrix.brandId }} + if: ${{ inputs.upload && !cancelled() && needs.desktop.result == 'success' && needs.mobile.result == 'success' }} + needs: [prepare, desktop, mobile] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} + R2_ACCOUNT_ID: ${{ secrets[format('{0}_R2_ACCOUNT_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} + R2_SECRET_ACCESS_KEY: ${{ secrets[format('{0}_R2_SECRET_ACCESS_KEY', matrix.distribution.desktop.credentialSecretPrefix)] }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Validate every upload credential + run: | + set -euo pipefail + node .github/scripts/release-inputs.cjs --phase upload --platform desktop + node .github/scripts/release-inputs.cjs --phase upload --platform mobile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: desktop-${{ matrix.brandId }}-* + merge-multiple: true + path: publish-preflight/${{ matrix.brandId }}/desktop + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: mobile-${{ matrix.brandId }}-* + merge-multiple: true + path: publish-preflight/${{ matrix.brandId }}/mobile + - name: Re-hash every signed artifact and immutable binding + run: | + set -euo pipefail + for runner_platform in mac win linux; do + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "publish-preflight/${{ matrix.brandId }}/desktop" \ + --verify "publish-preflight/${{ matrix.brandId }}/desktop/release-provenance.${runner_platform}.json" \ + --bundle apps/desktop/generated/config-build-bundle.json \ + --brand-identity apps/desktop/generated/brand-identity.json \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --expected-brand '${{ matrix.brandId }}' \ + --expected-platform desktop \ + --release-manifest release-inputs/release-manifest.desktop.json \ + --signed + done + for platform in ios android; do + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "publish-preflight/${{ matrix.brandId }}/mobile" \ + --verify "publish-preflight/${{ matrix.brandId }}/mobile/release-provenance.${platform}.json" \ + --bundle "apps/mobile/src/runtime/config/bundled.generated.${platform}.ts" \ + --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --expected-brand '${{ matrix.brandId }}' \ + --expected-platform "$platform" \ + --release-manifest "release-inputs/release-manifest.${platform}.json" \ + --signed + done + + publish-mobile: + name: Publish mobile ${{ matrix.brandId }} + if: ${{ inputs.upload && !cancelled() && needs.desktop.result == 'success' && needs.mobile.result == 'success' && needs.publish-preflight.result == 'success' }} + needs: [prepare, desktop, mobile, publish-preflight] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: ./.github/actions/setup-eas + - run: pnpm install --frozen-lockfile + - name: Validate upload inputs + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: node .github/scripts/release-inputs.cjs --phase upload --platform mobile + - name: Fetch brand release inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - name: Fetch signed mobile artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: mobile-${{ matrix.brandId }}-* + merge-multiple: true + path: artifacts/${{ matrix.brandId }} + - name: Require provenance and apply internal-store destinations + run: | + set -euo pipefail + for platform in ios android; do + test -s "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "artifacts/${{ matrix.brandId }}" \ + --verify "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" \ + --bundle "apps/mobile/src/runtime/config/bundled.generated.${platform}.ts" \ + --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --expected-brand '${{ matrix.brandId }}' \ + --expected-platform "$platform" \ + --release-manifest "release-inputs/release-manifest.${platform}.json" \ + --signed + done + release=release-inputs/mobile-release.json + asc_app_id="$(jq -er .ios.ascAppId "$release")" + apple_team_id="$(jq -er .ios.appleTeamId "$release")" + android_track="$(jq -er .android.track "$release")" + ios_bundle_id="$(jq -er .applicationId apps/mobile/generated/brand-identity.ios.json)" + tmp="$(mktemp)" + jq --arg asc "$asc_app_id" --arg team "$apple_team_id" --arg track "$android_track" --arg bundle "$ios_bundle_id" \ + '.submit.production.ios.ascAppId = $asc + | .submit.production.ios.appleTeamId = $team + | .submit.production.ios.bundleIdentifier = $bundle + | del(.submit.production.ios.metadataPath) + | .submit.production.android.track = $track' \ + apps/mobile/eas.json > "$tmp" + mv "$tmp" apps/mobile/eas.json + - name: Submit only to TestFlight and Play internal + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + set -euo pipefail + eas submit --platform ios --profile production \ + --path "$GITHUB_WORKSPACE/artifacts/${{ matrix.brandId }}/linkcode-ios.ipa" \ + --non-interactive --wait + eas submit --platform android --profile production \ + --path "$GITHUB_WORKSPACE/artifacts/${{ matrix.brandId }}/linkcode-android.aab" \ + --non-interactive --wait + + publish-desktop: + name: Publish desktop ${{ matrix.brandId }} + if: ${{ inputs.upload && !cancelled() && needs.desktop.result == 'success' && needs.mobile.result == 'success' && needs.publish-preflight.result == 'success' }} + needs: [prepare, desktop, mobile, publish-preflight] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + env: + AWS_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} + AWS_SECRET_ACCESS_KEY: ${{ secrets[format('{0}_R2_SECRET_ACCESS_KEY', matrix.distribution.desktop.credentialSecretPrefix)] }} + R2_ACCOUNT_ID: ${{ secrets[format('{0}_R2_ACCOUNT_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Validate upload inputs + env: + R2_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }} + run: node .github/scripts/release-inputs.cjs --phase upload --platform desktop + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: desktop-${{ matrix.brandId }}-* + merge-multiple: true + path: artifacts/${{ matrix.brandId }} + - name: Require per-platform provenance + run: | + set -euo pipefail + for platform in mac win linux; do + test -s "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "artifacts/${{ matrix.brandId }}" \ + --verify "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" \ + --bundle apps/desktop/generated/config-build-bundle.json \ + --brand-identity apps/desktop/generated/brand-identity.json \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --expected-brand '${{ matrix.brandId }}' \ + --expected-platform desktop \ + --release-manifest release-inputs/release-manifest.desktop.json \ + --signed + done + - name: Upload only this brand prefix + env: + AWS_REGION: auto + AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED + AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + run: | + aws s3 sync "artifacts/${{ matrix.brandId }}/" \ + "s3://${{ matrix.distribution.desktop.r2Bucket }}/${{ matrix.distribution.desktop.r2Prefix }}/" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --no-progress From af2978a73afddb34aad451e9d3b291187afb7096 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:58:35 +0000 Subject: [PATCH 19/20] docs(release): document brand matrix contract Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- docs/ENVIRONMENT.md | 8 +++- docs/RELEASE.md | 90 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 96760064..677e0e7f 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -109,7 +109,9 @@ client configuration or new build. | `RENDERER_VITE_*`, `VITE_*` | `apps/desktop/vite.renderer.config.ts` | The only prefixes exposed to desktop renderer code (`envDir` is `apps/desktop`). | | `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` | `apps/desktop/scripts/stage-sidecar.mts` | `aarch64-linux-gnu-gcc` for the linux-arm64 sidecar cross-build. | | `NODE_OPTIONS` | `.github/workflows/ci.yml` | `--max-old-space-size=4096` for every CI job. | -| `POSTHOG_HOST` | `build-mobile.yml` | Organization Actions variable mapped to `EXPO_PUBLIC_POSTHOG_HOST` for the production bundle. | +| `POSTHOG_HOST` | desktop/mobile build workflows | Organization Actions variable mapped to the platform-specific PostHog host for production bundles. | +| `BRAND_BUILD_MATRIX` | `release-brand-matrix.yml` | Repository Actions var containing the reviewed strict brand × platform JSON matrix. A manual `matrix_json` input may replace it only for plan validation; builds reject that override. It contains only public release bindings and destination identifiers, never credentials. | +| `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars. Repository name plus exact revision/public-keyring JSON bytes; release manifests digest-bind the JSON inputs. | ## Release-only secrets @@ -123,10 +125,12 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, `APPLE_TEAM_ID` | `build-desktop.yml` | notarytool key identity and team. | | `EXPO_TOKEN` | `build-mobile.yml` | Expo robot-user token with access to the LinkCode EAS project, managed build credentials, remote build versions, and EAS Submit. Store it in `release` only after enabling required reviewers and deployment branch/tag restrictions. | | `SENTRY_AUTH_TOKEN` | `build-mobile.yml` | Organization Actions secret that uploads production mobile source maps. Local EAS Build cannot read an EAS variable with Secret visibility, so GitHub must inject it. | -| `SENTRY_DSN_MOBILE`, `POSTHOG_PROJECT_TOKEN` | `build-mobile.yml` | Mapped to the mobile `EXPO_PUBLIC_*` build-time variables. These are publishable identifiers, but the repository currently carries them as Actions secrets. | +| `SENTRY_DSN_DESKTOP`, `SENTRY_DSN_MOBILE`, `POSTHOG_PROJECT_TOKEN` | desktop/mobile build workflows | Mapped to platform build-time telemetry variables. These are publishable identifiers, but the repository currently carries them as Actions secrets. | | `AZURE_PUBLISHER_NAME`, `AZURE_SIGN_ENDPOINT`, `AZURE_CODE_SIGNING_ACCOUNT`, `AZURE_CERTIFICATE_PROFILE` | `build-desktop.yml` | Windows Trusted Signing identifiers (not credentials, but kept as secrets so the public repo doesn't advertise the signing infrastructure). `AZURE_PUBLISHER_NAME` must match the certificate subject CN exactly. | | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` | `build-desktop.yml` | `azure/login` **inputs** for OIDC federation. No `AZURE_*` credential env exists during packaging on purpose, so `DefaultAzureCredential` falls through to the Azure CLI entry. | | `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-desktop.yml` | Cloudflare R2 credentials for publishing the electron-updater feed. `AWS_REQUEST_CHECKSUM_CALCULATION`/`AWS_RESPONSE_CHECKSUM_VALIDATION` are pinned to `WHEN_REQUIRED` because R2 doesn't implement the checksums recent aws-cli sends. | +| `CONFIG_PUBLISHER_TOKEN` | release workflows | Fine-grained token with Contents read-only access to `CONFIG_PUBLISHER_REPO`; used only to fetch exact commits pinned by release manifests. | +| `_R2_ACCOUNT_ID`, `_R2_ACCESS_KEY_ID`, `_R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | Per-brand R2 account and S3 credentials. `` is the validated `credentialSecretPrefix` in that brand's matrix row. Scope each key pair to only that row's bucket/prefix with object read/write/list; never share one prefix between brands. | | `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | `release-please.yml`, `finalize-releases.yml`, `release-desktop.yml` | Repository/org-scoped GitHub App credentials. The App needs Contents, Issues, and Pull requests read/write on this repo so release-please can maintain PRs, draft Releases, and tags; the release environment also uses it for the Homebrew cask bump and the WinGet bump (install the App on `arcboxlabs/homebrew-tap` and on the `arcboxlabs/winget-pkgs` fork with contents + pull-requests write). Missing credentials fail release automation before any tag is created; only the package-manager bumps remain an optional self-skip. | Mobile certificates, provisioning profiles, the Android keystore, the App Store Connect API key, diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 16d1fc0c..1d9a0118 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -4,15 +4,18 @@ How to cut, sign, notarize, and publish the Electron desktop app, plus the packa ## Release surface -- Six GitHub Actions workflows, one script module, and one composite action own the release path: +- Seven GitHub Actions workflows, three script modules, and two composite actions own the release path: - `.github/workflows/ci.yml` ("CI") — runs on every PR. - `.github/workflows/release-please.yml` ("Release Please") — maintains release PRs after pushes to `master`; it never tags or publishes. - `.github/workflows/finalize-releases.yml` ("Finalize Releases") — after a successful `master` CI, turns a merged release PR into a draft Release and pushes its validated tag. - `.github/workflows/build-desktop.yml` ("Build Desktop") — reusable packaging workflow; **not** PR-triggered. - `.github/workflows/release-desktop.yml` ("Release Desktop") — tag-triggered publish. - `.github/workflows/build-mobile.yml` ("Build Mobile") — manual Android/iOS production builds on GitHub runners, with optional EAS Submit. + - `.github/workflows/release-brand-matrix.yml` ("Release Brand Matrix") — strict brand × Desktop/iOS/Android orchestration, isolated artifacts, compliance, provenance, and optional signing/upload. - `.github/scripts/release-automation.cjs` — tested Octokit policy for candidate resolution, recovery, and Release preflight checks. + - `.github/scripts/brand-matrix.cjs` / `release-inputs.cjs` — fail-closed matrix and release-input validation. - `.github/actions/build-sidecar` — composite action that builds the PTY sidecar per arch. + - `.github/actions/render-release-config` — renders only through the exact publisher/source commits pinned by each release manifest. - All jobs run on **Blacksmith** runners, not stock GitHub: `blacksmith-2vcpu-ubuntu-2404` (CI + the publish job), and the `build-desktop` matrix uses `blacksmith-6vcpu-macos-26` (arm64/M4; Xcode 26 so `actool >= 26` compiles `mac.icon` into `Assets.car`), `blacksmith-4vcpu-windows-2025` (VS Build Tools, enough for NSIS), and `blacksmith-4vcpu-ubuntu-2204` (older glibc for broader AppImage compatibility). ## CI topology & merge gates @@ -97,6 +100,91 @@ Inputs live in the GitHub **`release` environment** and a missing value fails th Enforcement: `LINKCODE_REQUIRE_CONFIG_BUNDLE=1` (set for signed desktop builds) makes the Vite main build fail without `apps/desktop/generated/config-build-bundle.json` and makes `verify-artifacts.mts` require the staged asar copy, which is always byte-compared against the generated render. Mobile gates twice: `pnpm -F @linkcode/mobile config:verify-release` before `eas build`, and the `eas-build-pre-install` hook inside the EAS project archive rejects the committed `{ bundle: null }` sentinel on production profiles (the root `.easignore` — which replaces `.gitignore` for EAS archiving — deliberately lets the generated modules into the archive). +## Brand × platform release matrix + +`release-brand-matrix.yml` is manually dispatched against the exact lowercase 40-hex commit that +loaded the workflow (`inputs.ref == github.sha`), so protected workflow code, environment ref policy, +local actions, and client source have one trust root. Plan-only requests +may supply `matrix_json`; every build must use the reviewed repository Actions variable +`BRAND_BUILD_MATRIX`. `build`, `sign`, and `upload` are independent, monotonic gates: signing requires a build, +and upload requires signing. The default (`false` for all three) only validates the matrix and +needs no credential. `build: true, sign: false` renders one immutable target set per brand, creates +unsigned Desktop packages, and validates production-Hermes exports plus iOS/Android prebuilds. +Nothing is signed or submitted in that path. + +The JSON root contains `brandBuildMatrixVersion: 1` and a non-empty `brands` array. Every brand has +exactly `brandId`, `channel`, `releaseManifests`, `compliance`, and `distribution`: + +- `releaseManifests.desktop|ios|android` are complete release-render manifest v1 objects. The three + targets must share publisher/source commits, config revision, revision digest, and public-keyring + digest; target brand/platform/channel mismatches are rejected. +- `compliance.desktop|ios|android` has a lexicographically sorted `disclosedFeatures` array and a + checklist with all five keys set to `true`: `configurableFeaturesDisclosed`, + `dataPracticesReviewed`, `noExecutableCode`, `permissionsReviewed`, and `storeMetadataReviewed`. +- `distribution.desktop` may be `null` only for plan validation. Every build requires an object containing + `credentialSecretPrefix`, `r2Bucket`, `r2Prefix`, and `updateUrl`. Both URL and prefix must end in + the same brand/channel path; prefixes in one bucket must not overlap, and credential prefixes must be unique across brands. +- `distribution.mobile` may be `null` only for plan validation. Every build requires `easProjectId`, its + exact `https://u.expo.dev/` URL, iOS `appleTeamId`/`ascAppId`, and Android + `track: "internal"`. EAS project IDs and App Store Connect app IDs must be unique across brands. + +After publisher rendering, the gate extracts the actual bundled defaults and requires the +feature/module keys to match `disclosedFeatures` exactly. Review-like keys outside that disclosure +surface, executable-code key segments (`script`, `code`, `wasm`, `plugin`, `command`, and binary +variants), executable URL/file suffixes, and script-like strings fail before any signing starts. +This configuration layer is data-only: it cannot fetch/execute a module or silently enable a +store-review mode. A mobile distribution overlay can set only EAS project/update routing, Apple +team/App Store Connect IDs, and the internal Android track; all other fields are rejected. + +Every uploaded build has a canonical `release-provenance..json`. Each listed artifact is +bound by its own SHA-256 and size to the exact `brands.manifest.yaml` SHA-256, config revision ID, +canonical bundled-defaults SHA-256, config snapshot SHA-256, source/publisher commits, and release +manifest SHA-256, while the sidecar also records the exact client commit. Publish jobs re-hash the +artifacts and all immutable inputs before upload. The sidecar is written with create-only semantics after all checks pass. +Brand render jobs, artifact names, runner workspaces, validation roots, credential pairs, and R2 +prefixes are separate. Render jobs preserve successful sibling evidence when another row fails, +while aggregate build and publish-preflight jobs require every brand's five provenance sidecars and +upload inputs before any store submission or R2 upload can begin. + +### Required Actions configuration and least privilege + +Secrets and render vars below are read only from the protected `release` environment; +`BRAND_BUILD_MATRIX` is a repository Actions var because it contains no credential and the +credential-free plan job does not enter an environment. The scripts report every missing name and +never default a signing or upload input: + +- Vars: `BRAND_BUILD_MATRIX`, `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, + `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. Revision/keyring values are exact JSON bytes already digest-pinned by each + release manifest. +- Config source: secret `CONFIG_PUBLISHER_TOKEN`, a fine-grained token with **Contents: read** only + on `CONFIG_PUBLISHER_REPO`; no write or organization scope. +- macOS Desktop: `MACOS_CSC_LINK`, `MACOS_CSC_KEY_PASSWORD`, `APPLE_API_KEY_BASE64`, + `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and `APPLE_TEAM_ID`. The App Store Connect API key needs + only Developer ID notarization access; it must not have app-management or finance roles. +- Windows Desktop: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_PUBLISHER_NAME`, + `AZURE_SIGN_ENDPOINT`, `AZURE_CODE_SIGNING_ACCOUNT`, and `AZURE_CERTIFICATE_PROFILE`. The Azure + app has only the Trusted Signing certificate-profile signer role and an OIDC subject restricted + to this repository's `release` environment; no client secret exists. +- Desktop observability: `SENTRY_DSN_DESKTOP` and the shared `POSTHOG_PROJECT_TOKEN` plus + `POSTHOG_HOST` var. These are required publishable identifiers, not signing credentials. +- Mobile: `EXPO_TOKEN`, `SENTRY_AUTH_TOKEN`, `SENTRY_DSN_MOBILE`, and + `POSTHOG_PROJECT_TOKEN`. Issue `EXPO_TOKEN` to a robot account with access only to the matrix's EAS projects; + scope the Sentry token to source-map upload for the one mobile project. The DSN and PostHog values + are publishable identifiers but remain protected release inputs. + Native certificates, provisioning profiles, Android keystores, App Store Connect keys, and Google + Play service accounts stay EAS-managed and project-scoped. Submissions stop at TestFlight and the + Play internal track; this workflow never submits to App Review or promotes a Play release. +- Desktop upload: `_R2_ACCOUNT_ID`, `_R2_ACCESS_KEY_ID`, and + `_R2_SECRET_ACCESS_KEY` for each matrix `credentialSecretPrefix`. Each key pair is scoped to + that brand's one `r2Bucket/r2Prefix` with object read/write/list only; it must not access another + brand prefix or permit bucket/account administration. `_R2_ACCOUNT_ID` is exactly the + lowercase 32-hex Cloudflare account ID; URL-like or otherwise malformed values fail before AWS CLI runs. + +Do not store private signing material, access tokens, or service-account JSON in +`BRAND_BUILD_MATRIX`, repository files, artifacts, or Actions vars. Protect the `release` +environment with required reviewers and exact deployment ref rules before enabling `sign` or +`upload`. + ## Packaging inputs (staging & version pins) - **Per-arch single-importer staging (CODE-107).** electron-builder never packs `apps/desktop` in place; `apps/desktop/scripts/package-app.mts` runs `pnpm --prod deploy --legacy --cpu=` into one self-contained dir per target architecture **outside** the workspace, then invokes electron-builder once per dir. This is load-bearing twice: selecting one CPU keeps napi-rs optional bindings target-pure, while `appDir === projectDir === workspaceRoot` makes `@electron/rebuild` find better-sqlite3 on Windows and keeps the module collector on one importer. Separate macOS/Windows invocations target the same updater manifest, so the script merges their `files` arrays afterward while retaining x64 as the legacy `path`/`sha512`; Linux already names updater manifests per architecture. CI runs `node scripts/package-app.mts --publish never …` in place of a bare `electron-builder`. From dab0be6e652f7593b4ab434995ebf46960e7dcbd Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 8 Aug 2026 09:52:58 +0000 Subject: [PATCH 20/20] fix(release): harden brand matrix trust --- .github/scripts/brand-matrix.cjs | 12 +-- .github/scripts/brand-matrix.test.mjs | 19 ++++- .github/workflows/build-desktop.yml | 9 ++- .github/workflows/build-mobile.yml | 7 ++ .github/workflows/release-brand-matrix.yml | 78 +++++++++++++++++-- docs/ENVIRONMENT.md | 1 - docs/RELEASE.md | 42 +++++----- .../node/__tests__/release-artifact.test.ts | 37 +++++++++ .../common/src/node/release-artifact-cli.mts | 15 +++- .../common/src/node/release-artifact.ts | 30 +++++++ 10 files changed, 210 insertions(+), 40 deletions(-) diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs index 7710056e..1f135ef1 100644 --- a/.github/scripts/brand-matrix.cjs +++ b/.github/scripts/brand-matrix.cjs @@ -294,6 +294,7 @@ function strictBoolean(value, name) { } function runCli(argv = process.argv.slice(2), env = process.env) { + const { createHash } = require('node:crypto'); const { appendFileSync, readFileSync } = require('node:fs'); const { parseArgs } = require('node:util'); const { values } = parseArgs({ @@ -306,15 +307,14 @@ function runCli(argv = process.argv.slice(2), env = process.env) { }, strict: true, }); - const text = values['matrix-file'] - ? readFileSync(values['matrix-file'], 'utf8') - : env.BRAND_BUILD_MATRIX; - if (!text) fail('BRAND_BUILD_MATRIX', 'must be set or supplied with --matrix-file'); + if (!values['matrix-file']) fail('--matrix-file', 'is required'); + const bytes = readFileSync(values['matrix-file']); + const text = bytes.toString('utf8'); let matrix; try { matrix = JSON.parse(text); } catch { - fail('BRAND_BUILD_MATRIX', 'must be valid JSON'); + fail('--matrix-file', 'must contain valid JSON'); } const plan = buildMatrixPlan(matrix, { build: strictBoolean(values.build, '--build'), @@ -323,6 +323,7 @@ function runCli(argv = process.argv.slice(2), env = process.env) { }); const outputs = [ `brands=${JSON.stringify(plan.brands)}`, + `delivery_descriptor_sha256=${createHash('sha256').update(bytes).digest('hex')}`, `targets=${JSON.stringify(plan.targets)}`, ]; if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${outputs.join('\n')}\n`); @@ -338,4 +339,5 @@ module.exports = { PLATFORMS, buildMatrixPlan, parseBrandBuildMatrix, + runCli, }; diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index ab34d3f7..9b74dc37 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -1,7 +1,11 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import matrixModule from './brand-matrix.cjs'; -const { buildMatrixPlan, parseBrandBuildMatrix } = matrixModule; +const { buildMatrixPlan, parseBrandBuildMatrix, runCli } = matrixModule; const RE_WRONG_BRAND = /must target acme\/ios\/canary/; const RE_WRONG_PLATFORM = /must target acme\/android\/canary/; const RE_UNCHECKED = /noExecutableCode: must be true/; @@ -208,4 +212,17 @@ describe('parseBrandBuildMatrix', () => { divergent.brands[0].releaseManifests.ios.sourceGitSha = gitSha('f'); expect(() => parseBrandBuildMatrix(divergent)).toThrow(RE_DIVERGENT_SOURCE); }); + + it('emits the digest of the exact matrix-file bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'brand-matrix-')); + const matrixPath = join(root, 'matrix.json'); + const outputPath = join(root, 'github-output'); + const bytes = `${JSON.stringify(matrix(brand()))}\n`; + await writeFile(matrixPath, bytes); + runCli(['--matrix-file', matrixPath], { GITHUB_OUTPUT: outputPath }); + const output = await readFile(outputPath, 'utf8'); + expect(output).toContain( + `delivery_descriptor_sha256=${createHash('sha256').update(bytes).digest('hex')}\n`, + ); + }); }); diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 9aa5ac1e..1ecbe8fb 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -7,7 +7,7 @@ name: Build Desktop on: - # Invoked by release-desktop.yml (caller must use `secrets: inherit`). + # Release-environment secrets resolve in the called jobs; callers need not forward all secrets. workflow_call: inputs: ref: @@ -25,6 +25,11 @@ on: type: string required: false default: "" + delivery_descriptor_sha256: + description: Expected SHA-256 of the committed brand delivery matrix + type: string + required: false + default: "" rendered_artifact: description: Pre-rendered brand/config artifact from the matrix workflow type: string @@ -302,6 +307,8 @@ jobs: --brand-identity apps/desktop/generated/brand-identity.json \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ inputs.delivery_descriptor_sha256 }}' \ --release-manifest release-inputs/release-manifest.desktop.json \ --compliance release-inputs/compliance.desktop.json \ --out "release-provenance.${{ matrix.platform }}.json" \ diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 1ad5f5f7..dbf97624 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -16,6 +16,11 @@ on: type: string required: false default: "" + delivery_descriptor_sha256: + description: Expected SHA-256 of the committed brand delivery matrix + type: string + required: false + default: "" rendered_artifact: description: Pre-rendered brand/config artifact from the matrix workflow type: string @@ -217,6 +222,8 @@ jobs: --brand-identity "apps/mobile/generated/brand-identity.${{ matrix.platform }}.json" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ inputs.delivery_descriptor_sha256 }}' \ --release-manifest "release-inputs/release-manifest.${{ matrix.platform }}.json" \ --compliance "release-inputs/compliance.${{ matrix.platform }}.json" \ --out "release-provenance.${{ matrix.platform }}.json" \ diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index b77e6f58..18bb0c5e 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -7,8 +7,13 @@ on: description: Exact client ref to build type: string required: true + matrix_file: + description: Reviewed matrix under .github/release/brand-matrices; required for builds + type: string + required: false + default: "" matrix_json: - description: Matrix JSON; empty reads vars.BRAND_BUILD_MATRIX + description: Uncommitted matrix JSON for plan validation only type: string required: false default: "" @@ -41,11 +46,14 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} outputs: brands: ${{ steps.matrix.outputs.brands }} + delivery_descriptor_sha256: ${{ steps.matrix.outputs.delivery_descriptor_sha256 }} targets: ${{ steps.matrix.outputs.targets }} steps: - name: Validate request shape env: CLIENT_REF: ${{ inputs.ref }} + MATRIX_FILE: ${{ inputs.matrix_file }} + MATRIX_JSON: ${{ inputs.matrix_json }} WORKFLOW_SHA: ${{ github.sha }} run: | set -euo pipefail @@ -57,27 +65,66 @@ jobs: echo "::error::ref must equal github.sha so workflow code, release-environment policy, and built client use one commit" exit 1 fi - if ${{ (inputs.sign && !inputs.build) || (inputs.upload && !inputs.sign) || (inputs.matrix_json != '' && inputs.build) }}; then - echo "::error::sign requires build=true; upload requires sign=true; matrix_json is plan-only and build requests must use the reviewed BRAND_BUILD_MATRIX variable" + if ${{ (inputs.sign && !inputs.build) || (inputs.upload && !inputs.sign) }}; then + echo "::error::sign requires build=true and upload requires sign=true" + exit 1 + fi + if { [ -z "$MATRIX_FILE" ] && [ -z "$MATRIX_JSON" ]; } || { [ -n "$MATRIX_FILE" ] && [ -n "$MATRIX_JSON" ]; }; then + echo "::error::provide exactly one of matrix_file or matrix_json" + exit 1 + fi + if [ -n "$MATRIX_JSON" ] && ${{ inputs.build }}; then + echo "::error::matrix_json is plan-only; builds require a reviewed matrix_file" + exit 1 + fi + if [ -n "$MATRIX_FILE" ] && [[ ! "$MATRIX_FILE" =~ ^\.github/release/brand-matrices/[a-z0-9][a-z0-9._-]*\.json$ ]]; then + echo "::error::matrix_file must be a JSON file directly under .github/release/brand-matrices" exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref }} + fetch-depth: 0 - - name: Verify exact client checkout + - name: Verify trusted client checkout env: CLIENT_REF: ${{ inputs.ref }} + MATRIX_FILE: ${{ inputs.matrix_file }} run: | set -euo pipefail test "$(git rev-parse HEAD)" = "$CLIENT_REF" + if ! git merge-base --is-ancestor "$CLIENT_REF" refs/remotes/origin/master; then + echo "::error::ref must be a commit already reachable from protected master" + exit 1 + fi + if [ -n "$MATRIX_FILE" ]; then + entry="$(git ls-tree "$CLIENT_REF" -- "$MATRIX_FILE")" + if [[ "$entry" != '100644 blob '* ]]; then + echo "::error::matrix_file must be a regular file committed at ref" + exit 1 + fi + git cat-file blob "$CLIENT_REF:$MATRIX_FILE" > "$RUNNER_TEMP/committed-brand-matrix.json" + fi - name: Build strict matrix plan id: matrix env: - BRAND_BUILD_MATRIX: ${{ inputs.matrix_json || vars.BRAND_BUILD_MATRIX }} - run: node .github/scripts/brand-matrix.cjs --build "${{ inputs.build }}" --sign "${{ inputs.sign }}" --upload "${{ inputs.upload }}" + MATRIX_FILE: ${{ inputs.matrix_file }} + MATRIX_JSON: ${{ inputs.matrix_json }} + run: | + set -euo pipefail + if [ -n "$MATRIX_JSON" ]; then + matrix_file="$RUNNER_TEMP/brand-build-matrix.json" + printf '%s' "$MATRIX_JSON" > "$matrix_file" + else + matrix_file="$RUNNER_TEMP/committed-brand-matrix.json" + fi + node .github/scripts/brand-matrix.cjs \ + --matrix-file "$matrix_file" \ + --build "${{ inputs.build }}" \ + --sign "${{ inputs.sign }}" \ + --upload "${{ inputs.upload }}" render-inputs: name: Validate immutable render inputs @@ -183,6 +230,8 @@ jobs: COMPLIANCE_ANDROID: ${{ toJSON(matrix.compliance.android) }} COMPLIANCE_DESKTOP: ${{ toJSON(matrix.compliance.desktop) }} COMPLIANCE_IOS: ${{ toJSON(matrix.compliance.ios) }} + CLIENT_REF: ${{ inputs.ref }} + MATRIX_FILE: ${{ inputs.matrix_file }} MANIFEST_ANDROID: ${{ toJSON(matrix.releaseManifests.android) }} MANIFEST_DESKTOP: ${{ toJSON(matrix.releaseManifests.desktop) }} MANIFEST_IOS: ${{ toJSON(matrix.releaseManifests.ios) }} @@ -190,6 +239,7 @@ jobs: run: | set -euo pipefail mkdir release-inputs + git cat-file blob "$CLIENT_REF:$MATRIX_FILE" > release-inputs/brand-build-matrix.json cp "$RUNNER_TEMP/config-render-desktop/source/packages/config-structural/brands.manifest.yaml" release-inputs/ printf '%s' "$MANIFEST_DESKTOP" > release-inputs/release-manifest.desktop.json printf '%s' "$MANIFEST_IOS" > release-inputs/release-manifest.ios.json @@ -228,6 +278,8 @@ jobs: --brand-identity "$identity" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ --compliance "release-inputs/compliance.${platform}.json" \ --out "${platform}.provenance.json" @@ -254,7 +306,6 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} uses: ./.github/workflows/build-desktop.yml - secrets: inherit permissions: contents: read id-token: write @@ -262,6 +313,7 @@ jobs: ref: ${{ inputs.ref }} sign: ${{ inputs.sign }} brand_id: ${{ matrix.brandId }} + delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} @@ -306,6 +358,8 @@ jobs: --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ --compliance "release-inputs/compliance.${platform}.json" \ --out "release-provenance.${platform}.json" @@ -325,10 +379,10 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} uses: ./.github/workflows/build-mobile.yml - secrets: inherit with: ref: ${{ inputs.ref }} brand_id: ${{ matrix.brandId }} + delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} rendered_artifact: brand-render-${{ matrix.brandId }} submit: false @@ -389,6 +443,8 @@ jobs: --brand-identity apps/desktop/generated/brand-identity.json \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ --expected-platform desktop \ --release-manifest release-inputs/release-manifest.desktop.json \ @@ -402,6 +458,8 @@ jobs: --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ --expected-platform "$platform" \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ @@ -450,6 +508,8 @@ jobs: --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ --expected-platform "$platform" \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ @@ -534,6 +594,8 @@ jobs: --brand-identity apps/desktop/generated/brand-identity.json \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ --expected-platform desktop \ --release-manifest release-inputs/release-manifest.desktop.json \ diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 677e0e7f..18b34708 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -110,7 +110,6 @@ client configuration or new build. | `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` | `apps/desktop/scripts/stage-sidecar.mts` | `aarch64-linux-gnu-gcc` for the linux-arm64 sidecar cross-build. | | `NODE_OPTIONS` | `.github/workflows/ci.yml` | `--max-old-space-size=4096` for every CI job. | | `POSTHOG_HOST` | desktop/mobile build workflows | Organization Actions variable mapped to the platform-specific PostHog host for production bundles. | -| `BRAND_BUILD_MATRIX` | `release-brand-matrix.yml` | Repository Actions var containing the reviewed strict brand × platform JSON matrix. A manual `matrix_json` input may replace it only for plan validation; builds reject that override. It contains only public release bindings and destination identifiers, never credentials. | | `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars. Repository name plus exact revision/public-keyring JSON bytes; release manifests digest-bind the JSON inputs. | ## Release-only secrets diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 1d9a0118..68d241d2 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -103,14 +103,16 @@ Enforcement: `LINKCODE_REQUIRE_CONFIG_BUNDLE=1` (set for signed desktop builds) ## Brand × platform release matrix `release-brand-matrix.yml` is manually dispatched against the exact lowercase 40-hex commit that -loaded the workflow (`inputs.ref == github.sha`), so protected workflow code, environment ref policy, -local actions, and client source have one trust root. Plan-only requests -may supply `matrix_json`; every build must use the reviewed repository Actions variable -`BRAND_BUILD_MATRIX`. `build`, `sign`, and `upload` are independent, monotonic gates: signing requires a build, -and upload requires signing. The default (`false` for all three) only validates the matrix and -needs no credential. `build: true, sign: false` renders one immutable target set per brand, creates -unsigned Desktop packages, and validates production-Hermes exports plus iOS/Android prebuilds. -Nothing is signed or submitted in that path. +loaded the workflow (`inputs.ref == github.sha`) and rejects commits not already reachable from +`master`, so protected workflow code, environment ref policy, local actions, and client source have +one trust root. Plan-only requests may supply `matrix_json`; every build instead requires a +`matrix_file` directly under `.github/release/brand-matrices/` in that same reviewed commit. Add or +update the complete matrix through a PR before dispatching a release; Actions variables are not a +release-plan authority. `build`, `sign`, and `upload` are independent, monotonic gates: signing +requires a build, and upload requires signing. The default (`false` for all three) only validates +the selected matrix and needs no credential. `build: true, sign: false` renders one immutable target +set per brand, creates unsigned Desktop packages, and validates production-Hermes exports plus +iOS/Android prebuilds. Nothing is signed or submitted in that path. The JSON root contains `brandBuildMatrixVersion: 1` and a non-empty `brands` array. Every brand has exactly `brandId`, `channel`, `releaseManifests`, `compliance`, and `distribution`: @@ -139,8 +141,9 @@ team/App Store Connect IDs, and the internal Android track; all other fields are Every uploaded build has a canonical `release-provenance..json`. Each listed artifact is bound by its own SHA-256 and size to the exact `brands.manifest.yaml` SHA-256, config revision ID, canonical bundled-defaults SHA-256, config snapshot SHA-256, source/publisher commits, and release -manifest SHA-256, while the sidecar also records the exact client commit. Publish jobs re-hash the -artifacts and all immutable inputs before upload. The sidecar is written with create-only semantics after all checks pass. +manifest SHA-256, while the sidecar also records the exact client commit and committed matrix-file +SHA-256. Publish jobs re-hash the artifacts and all immutable inputs before upload. The sidecar is +written with create-only semantics after all checks pass. Brand render jobs, artifact names, runner workspaces, validation roots, credential pairs, and R2 prefixes are separate. Render jobs preserve successful sibling evidence when another row fails, while aggregate build and publish-preflight jobs require every brand's five provenance sidecars and @@ -148,14 +151,12 @@ upload inputs before any store submission or R2 upload can begin. ### Required Actions configuration and least privilege -Secrets and render vars below are read only from the protected `release` environment; -`BRAND_BUILD_MATRIX` is a repository Actions var because it contains no credential and the -credential-free plan job does not enter an environment. The scripts report every missing name and -never default a signing or upload input: +Secrets and render vars below are read only from the protected `release` environment. The scripts +report every missing name and never default a signing or upload input: -- Vars: `BRAND_BUILD_MATRIX`, `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, - `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. Revision/keyring values are exact JSON bytes already digest-pinned by each - release manifest. +- Vars: `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS`, and + `POSTHOG_HOST`. Revision/keyring values are exact JSON bytes already digest-pinned by each release + manifest. - Config source: secret `CONFIG_PUBLISHER_TOKEN`, a fine-grained token with **Contents: read** only on `CONFIG_PUBLISHER_REPO`; no write or organization scope. - macOS Desktop: `MACOS_CSC_LINK`, `MACOS_CSC_KEY_PASSWORD`, `APPLE_API_KEY_BASE64`, @@ -180,10 +181,9 @@ never default a signing or upload input: brand prefix or permit bucket/account administration. `_R2_ACCOUNT_ID` is exactly the lowercase 32-hex Cloudflare account ID; URL-like or otherwise malformed values fail before AWS CLI runs. -Do not store private signing material, access tokens, or service-account JSON in -`BRAND_BUILD_MATRIX`, repository files, artifacts, or Actions vars. Protect the `release` -environment with required reviewers and exact deployment ref rules before enabling `sign` or -`upload`. +Do not store private signing material, access tokens, or service-account JSON in the committed +matrix, repository files, artifacts, or Actions vars. Protect the `release` environment with +required reviewers and exact deployment ref rules before enabling `sign` or `upload`. ## Packaging inputs (staging & version pins) diff --git a/packages/foundation/common/src/node/__tests__/release-artifact.test.ts b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts index f2aba0d7..67950baa 100644 --- a/packages/foundation/common/src/node/__tests__/release-artifact.test.ts +++ b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts @@ -49,6 +49,10 @@ const releaseManifest = { const RE_SHA256 = /^[0-9a-f]{64}$/; const RE_EXISTS = /EEXIST/; const clientGitSha = 'f'.repeat(40); +const deliveryDescriptorBytes = new TextEncoder().encode('{"brand":"acme"}'); +const expectedDeliveryDescriptorSha256 = createHash('sha256') + .update(deliveryDescriptorBytes) + .digest('hex'); describe('release artifact provenance', () => { it('binds each isolated artifact to the manifest, revision, and defaults digests', async () => { @@ -62,6 +66,8 @@ describe('release artifact provenance', () => { bundle, clientGitSha, compliance, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, releaseManifest, releaseManifestBytes: new TextEncoder().encode('{}'), signed: false, @@ -78,6 +84,7 @@ describe('release artifact provenance', () => { }); expect(provenance.artifacts[0]?.brandManifestSha256).toMatch(RE_SHA256); expect(provenance.artifacts[0]?.defaultsSha256).toMatch(RE_SHA256); + expect(provenance.deliveryDescriptorSha256).toBe(expectedDeliveryDescriptorSha256); expect(provenance.artifacts[0]?.brandManifestSha256).toBe( createHash('sha256').update('brands: [acme]').digest('hex'), ); @@ -209,6 +216,8 @@ describe('release artifact provenance', () => { bundle, clientGitSha, compliance, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, releaseManifest, releaseManifestBytes: new Uint8Array(), signed: false, @@ -234,6 +243,7 @@ describe('release artifact provenance', () => { channel: 'canary', clientGitSha, configSnapshotSha256: 'a'.repeat(64), + deliveryDescriptorSha256: 'e'.repeat(64), platform: 'ios', publisherGitSha: 'b'.repeat(40), releaseArtifactProvenanceVersion: 1, @@ -261,6 +271,8 @@ describe('release artifact provenance', () => { bundle, clientGitSha, compliance, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, releaseManifest, releaseManifestBytes: new TextEncoder().encode('{}'), signed: true, @@ -273,6 +285,8 @@ describe('release artifact provenance', () => { brandId: bundle.brandId, bundle, clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, platform: bundle.platform, provenance, releaseManifest, @@ -280,6 +294,23 @@ describe('release artifact provenance', () => { signed: true, }), ).resolves.toStrictEqual(provenance); + await expect( + verifyReleaseArtifactProvenance({ + artifactRoot: root, + brandIdentity: identity, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + brandId: bundle.brandId, + bundle, + clientGitSha, + deliveryDescriptorBytes: new TextEncoder().encode('{"brand":"zenith"}'), + expectedDeliveryDescriptorSha256, + platform: bundle.platform, + provenance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: true, + }), + ).rejects.toThrow('reviewed release matrix'); await expect( verifyReleaseArtifactProvenance({ artifactRoot: root, @@ -288,6 +319,8 @@ describe('release artifact provenance', () => { brandId: 'zenith', bundle, clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, platform: bundle.platform, provenance, releaseManifest, @@ -303,6 +336,8 @@ describe('release artifact provenance', () => { brandId: bundle.brandId, bundle, clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, platform: bundle.platform, provenance, releaseManifest, @@ -319,6 +354,8 @@ describe('release artifact provenance', () => { brandId: bundle.brandId, bundle, clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, platform: bundle.platform, provenance, releaseManifest, diff --git a/packages/foundation/common/src/node/release-artifact-cli.mts b/packages/foundation/common/src/node/release-artifact-cli.mts index c3c519d2..e1285ef7 100644 --- a/packages/foundation/common/src/node/release-artifact-cli.mts +++ b/packages/foundation/common/src/node/release-artifact-cli.mts @@ -12,10 +12,13 @@ import { const USAGE = `Usage: release-artifact --artifact-root --artifact [--artifact ...] --bundle --brand-identity --brand-manifest - --release-manifest --compliance --client-git-sha --out [--signed] + --delivery-descriptor --release-manifest --compliance + --expected-delivery-sha256 --client-git-sha --out [--signed] release-artifact --artifact-root --verify - --bundle --brand-identity --brand-manifest --release-manifest - --client-git-sha --expected-brand --expected-platform [--signed]`; + --bundle --brand-identity --brand-manifest + --delivery-descriptor --release-manifest --client-git-sha + --expected-brand --expected-delivery-sha256 + --expected-platform [--signed]`; function bail(message: string): never { throw new TypeError(`release-artifact: ${message}\n\n${USAGE}`); @@ -105,7 +108,9 @@ async function main(): Promise { bundle: { type: 'string' }, 'client-git-sha': { type: 'string' }, compliance: { type: 'string' }, + 'delivery-descriptor': { type: 'string' }, 'expected-brand': { type: 'string' }, + 'expected-delivery-sha256': { type: 'string' }, 'expected-platform': { type: 'string' }, out: { type: 'string' }, 'release-manifest': { type: 'string' }, @@ -134,6 +139,8 @@ async function main(): Promise { brandId: required('expected-brand'), bundle: parsed.bundle, clientGitSha: required('client-git-sha'), + deliveryDescriptorBytes: await readFile(required('delivery-descriptor')), + expectedDeliveryDescriptorSha256: required('expected-delivery-sha256'), platform: required('expected-platform'), provenance: input.value, releaseManifest: releaseManifest(releaseInput.value), @@ -159,6 +166,8 @@ async function main(): Promise { bundle: parsed.bundle, clientGitSha: required('client-git-sha'), compliance: compliance(complianceInput.value), + deliveryDescriptorBytes: await readFile(required('delivery-descriptor')), + expectedDeliveryDescriptorSha256: required('expected-delivery-sha256'), releaseManifest: releaseManifest(releaseInput.value), releaseManifestBytes: releaseInput.bytes, signed: values.signed, diff --git a/packages/foundation/common/src/node/release-artifact.ts b/packages/foundation/common/src/node/release-artifact.ts index b14cd042..025c1314 100644 --- a/packages/foundation/common/src/node/release-artifact.ts +++ b/packages/foundation/common/src/node/release-artifact.ts @@ -40,6 +40,7 @@ export interface ReleaseArtifactProvenance { readonly channel: string; readonly clientGitSha: string; readonly configSnapshotSha256: string; + readonly deliveryDescriptorSha256: string; readonly platform: string; readonly publisherGitSha: string; readonly releaseArtifactProvenanceVersion: 1; @@ -55,6 +56,17 @@ function sha256(bytes: string | Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } +function deliveryDescriptorSha256(bytes: Uint8Array, expected: string): string { + if (!RE_SHA256.test(expected)) { + throw new TypeError('expectedDeliveryDescriptorSha256 must be a lowercase SHA-256 digest'); + } + const actual = sha256(bytes); + if (actual !== expected) { + throw new Error('delivery descriptor does not match the reviewed release matrix'); + } + return actual; +} + function assertReleaseBinding(bundle: ConfigBuildBundle, manifest: ReleaseManifestBinding): void { const checks = [ ['brandId', bundle.brandId, manifest.brandId], @@ -105,6 +117,8 @@ export async function createReleaseArtifactProvenance(input: { readonly bundle: ConfigBuildBundle; readonly clientGitSha: string; readonly compliance: StoreComplianceDeclaration; + readonly deliveryDescriptorBytes: Uint8Array; + readonly expectedDeliveryDescriptorSha256: string; readonly releaseManifest: ReleaseManifestBinding; readonly releaseManifestBytes: Uint8Array; readonly signed: boolean; @@ -124,6 +138,10 @@ export async function createReleaseArtifactProvenance(input: { const defaults = jsonValue(configBuildBundleDefaults(input.bundle)); const defaultsSha256 = sha256(canonicalizeJson(defaults)); const brandManifestSha256 = sha256(input.brandManifestBytes); + const deliverySha256 = deliveryDescriptorSha256( + input.deliveryDescriptorBytes, + input.expectedDeliveryDescriptorSha256, + ); const files = await Promise.all( [...input.artifactPaths].sort().map((path) => artifactFile(input.artifactRoot, path)), ); @@ -138,6 +156,7 @@ export async function createReleaseArtifactProvenance(input: { channel: input.bundle.channel, clientGitSha: input.clientGitSha, configSnapshotSha256: input.bundle.snapshot.sha256, + deliveryDescriptorSha256: deliverySha256, platform: input.bundle.platform, publisherGitSha: input.releaseManifest.publisherGitSha, releaseArtifactProvenanceVersion: 1, @@ -159,6 +178,7 @@ function releaseArtifactProvenance(value: unknown): ReleaseArtifactProvenance { 'channel', 'clientGitSha', 'configSnapshotSha256', + 'deliveryDescriptorSha256', 'platform', 'publisherGitSha', 'releaseArtifactProvenanceVersion', @@ -182,6 +202,8 @@ function releaseArtifactProvenance(value: unknown): ReleaseArtifactProvenance { !RE_GIT_SHA.test(provenance.clientGitSha) || typeof provenance.configSnapshotSha256 !== 'string' || !RE_SHA256.test(provenance.configSnapshotSha256) || + typeof provenance.deliveryDescriptorSha256 !== 'string' || + !RE_SHA256.test(provenance.deliveryDescriptorSha256) || typeof provenance.releaseManifestSha256 !== 'string' || !RE_SHA256.test(provenance.releaseManifestSha256) || typeof provenance.publisherGitSha !== 'string' || @@ -251,6 +273,7 @@ function releaseArtifactProvenance(value: unknown): ReleaseArtifactProvenance { channel: provenance.channel, clientGitSha: provenance.clientGitSha, configSnapshotSha256: provenance.configSnapshotSha256, + deliveryDescriptorSha256: provenance.deliveryDescriptorSha256, platform: provenance.platform, publisherGitSha: provenance.publisherGitSha, releaseArtifactProvenanceVersion: 1, @@ -267,6 +290,8 @@ export async function verifyReleaseArtifactProvenance(input: { readonly brandId: string; readonly bundle: ConfigBuildBundle; readonly clientGitSha: string; + readonly deliveryDescriptorBytes: Uint8Array; + readonly expectedDeliveryDescriptorSha256: string; readonly platform: string; readonly provenance: unknown; readonly releaseManifest: ReleaseManifestBinding; @@ -283,6 +308,10 @@ export async function verifyReleaseArtifactProvenance(input: { const defaultsSha256 = sha256( canonicalizeJson(jsonValue(configBuildBundleDefaults(input.bundle))), ); + const deliverySha256 = deliveryDescriptorSha256( + input.deliveryDescriptorBytes, + input.expectedDeliveryDescriptorSha256, + ); if ( provenance.brandId !== input.brandId || provenance.channel !== input.bundle.channel || @@ -290,6 +319,7 @@ export async function verifyReleaseArtifactProvenance(input: { provenance.signed !== input.signed || provenance.clientGitSha !== input.clientGitSha || provenance.configSnapshotSha256 !== input.bundle.snapshot.sha256 || + provenance.deliveryDescriptorSha256 !== deliverySha256 || provenance.publisherGitSha !== input.releaseManifest.publisherGitSha || provenance.releaseManifestSha256 !== sha256(input.releaseManifestBytes) || provenance.sourceGitSha !== input.bundle.provenance.sourceGitSha