diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index e0d7c166e..171ef99e9 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/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs new file mode 100644 index 000000000..7710056e3 --- /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, +}; diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs new file mode 100644 index 000000000..ab34d3f77 --- /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); + }); +}); diff --git a/.github/scripts/release-inputs.cjs b/.github/scripts/release-inputs.cjs new file mode 100644 index 000000000..2a780d3df --- /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 000000000..cf476a8fb --- /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); + }); +}); diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 111b4e0d8..9aa5ac1e4 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 5814db38e..1ad5f5f7e 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 diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml new file mode 100644 index 000000000..b77e6f585 --- /dev/null +++ b/.github/workflows/release-brand-matrix.yml @@ -0,0 +1,551 @@ +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 + + 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 diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ceecf0c35..9dfc22a0d 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 c8c45dbb3..9cb8e8913 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 df192d0c6..72f2575df 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. */ diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 96760064d..677e0e7f5 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 16d1fc0ca..1d9a01187 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`. 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 000000000..f2aba0d7b --- /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'); + }); +}); diff --git a/packages/foundation/common/src/node/index.ts b/packages/foundation/common/src/node/index.ts index 6c5526f0d..fe36b43c9 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-cli.mts b/packages/foundation/common/src/node/release-artifact-cli.mts new file mode 100644 index 000000000..c3c519d22 --- /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; +}); 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 000000000..b14cd042b --- /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) }; +} 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 000000000..21c893b52 --- /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), + ); +}