From 9889a2960609a74b99e14a8fd8e6dd3d47aedac4 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:54:41 +0000 Subject: [PATCH 01/21] feat(release): validate brand build matrix Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/scripts/brand-matrix.cjs | 341 +++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 .github/scripts/brand-matrix.cjs diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs new file mode 100644 index 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, +}; From d451576dc2c166bf16c63b61277cbeb6204ec971 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:55:04 +0000 Subject: [PATCH 02/21] test(release): cover brand matrix isolation Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/scripts/brand-matrix.test.mjs | 211 ++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 .github/scripts/brand-matrix.test.mjs diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs new file mode 100644 index 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); + }); +}); From d73f4122d069b4fe2f0d68edc393a1675f8e9393 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:55:12 +0000 Subject: [PATCH 03/21] feat(release): validate protected release inputs Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/scripts/release-inputs.cjs | 95 +++++++++++++++++++++++++ .github/scripts/release-inputs.test.mjs | 73 +++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 .github/scripts/release-inputs.cjs create mode 100644 .github/scripts/release-inputs.test.mjs diff --git a/.github/scripts/release-inputs.cjs b/.github/scripts/release-inputs.cjs new file mode 100644 index 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); + }); +}); From bbb5272c6d8ce1317538ed5df81dc13b2a2393c8 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:55:26 +0000 Subject: [PATCH 04/21] feat(mobile): bind branded release destinations Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- apps/mobile/app.config.ts | 12 ++- .../src/build/__tests__/expo-brand.test.ts | 29 +++++++ apps/mobile/src/build/expo-brand.ts | 81 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 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. */ From 4e6553c2cea0492a398c11a1b3634ff3181d8eec Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:55:55 +0000 Subject: [PATCH 05/21] feat(release): enforce store configuration compliance Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .../common/src/node/release-compliance.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/foundation/common/src/node/release-compliance.ts diff --git a/packages/foundation/common/src/node/release-compliance.ts b/packages/foundation/common/src/node/release-compliance.ts new file mode 100644 index 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), + ); +} From 1bc867a9547dd825b824ad8df933174a4f379f4e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:56:05 +0000 Subject: [PATCH 06/21] feat(release): bind artifact provenance Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- packages/foundation/common/src/node/index.ts | 1 + .../common/src/node/release-artifact.ts | 364 ++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 packages/foundation/common/src/node/release-artifact.ts diff --git a/packages/foundation/common/src/node/index.ts b/packages/foundation/common/src/node/index.ts index 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.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) }; +} From c2f8a6ccd869e818345fd192445426c9fe799e33 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:56:24 +0000 Subject: [PATCH 07/21] feat(release): add provenance verification CLI Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .../common/src/node/release-artifact-cli.mts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 packages/foundation/common/src/node/release-artifact-cli.mts diff --git a/packages/foundation/common/src/node/release-artifact-cli.mts b/packages/foundation/common/src/node/release-artifact-cli.mts new file mode 100644 index 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; +}); From e35f5dafc18e2f4f797fc64965b57479a57661ca Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:56:24 +0000 Subject: [PATCH 08/21] test(release): cover provenance isolation Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .../node/__tests__/release-artifact.test.ts | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 packages/foundation/common/src/node/__tests__/release-artifact.test.ts diff --git a/packages/foundation/common/src/node/__tests__/release-artifact.test.ts b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts new file mode 100644 index 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'); + }); +}); From 46fdaba063753080bd9b2ddd8361f11bfa3a858e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:56:50 +0000 Subject: [PATCH 09/21] feat(release): consume rendered brand artifacts Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .../actions/render-release-config/action.yml | 16 +++- .github/workflows/build-desktop.yml | 76 ++++++++++++++++--- .github/workflows/build-mobile.yml | 65 +++++++++++++--- 3 files changed, 136 insertions(+), 21 deletions(-) diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index 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/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 From 3ecf5d14405eb0e341fc5e92a7559e6330ba9c10 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:58:24 +0000 Subject: [PATCH 10/21] feat(release): build isolated brand matrix Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/workflows/release-brand-matrix.yml | 333 +++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 .github/workflows/release-brand-matrix.yml diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml new file mode 100644 index 000000000..021de2a87 --- /dev/null +++ b/.github/workflows/release-brand-matrix.yml @@ -0,0 +1,333 @@ +name: Release Brand Matrix + +on: + workflow_dispatch: + inputs: + ref: + description: Exact client ref to build + type: string + required: true + matrix_json: + description: Matrix JSON; empty reads vars.BRAND_BUILD_MATRIX + type: string + required: false + default: "" + build: + description: Render and build every brand/platform target + type: boolean + required: true + default: false + sign: + description: Sign/notarize desktop and mobile artifacts + type: boolean + required: true + default: false + upload: + description: Upload only after every signed artifact and provenance gate succeeds + type: boolean + required: true + default: false + +concurrency: + group: release-brand-matrix-${{ inputs.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + prepare: + name: Validate matrix + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + outputs: + brands: ${{ steps.matrix.outputs.brands }} + targets: ${{ steps.matrix.outputs.targets }} + steps: + - name: Validate request shape + env: + CLIENT_REF: ${{ inputs.ref }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + set -euo pipefail + if [[ ! "$CLIENT_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::ref must be an exact lowercase 40-hex client commit" + exit 1 + fi + if [ "$CLIENT_REF" != "$WORKFLOW_SHA" ]; then + echo "::error::ref must equal github.sha so workflow code, release-environment policy, and built client use one commit" + exit 1 + fi + if ${{ (inputs.sign && !inputs.build) || (inputs.upload && !inputs.sign) || (inputs.matrix_json != '' && inputs.build) }}; then + echo "::error::sign requires build=true; upload requires sign=true; matrix_json is plan-only and build requests must use the reviewed BRAND_BUILD_MATRIX variable" + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + + - name: Verify exact client checkout + env: + CLIENT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$CLIENT_REF" + + - name: Build strict matrix plan + id: matrix + env: + BRAND_BUILD_MATRIX: ${{ inputs.matrix_json || vars.BRAND_BUILD_MATRIX }} + run: node .github/scripts/brand-matrix.cjs --build "${{ inputs.build }}" --sign "${{ inputs.sign }}" --upload "${{ inputs.upload }}" + + render-inputs: + name: Validate immutable render inputs + if: ${{ inputs.build }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_PUBLISHER_TOKEN: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + CONFIG_RELEASE_KEYRINGS: ${{ vars.CONFIG_RELEASE_KEYRINGS }} + CONFIG_RELEASE_REVISION: ${{ vars.CONFIG_RELEASE_REVISION }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - run: node .github/scripts/release-inputs.cjs --phase render --platform desktop + + signing-inputs: + name: Validate signing inputs + if: ${{ inputs.sign }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + env: + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + AZURE_CERTIFICATE_PROFILE: ${{ secrets.AZURE_CERTIFICATE_PROFILE }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CODE_SIGNING_ACCOUNT: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT }} + AZURE_PUBLISHER_NAME: ${{ secrets.AZURE_PUBLISHER_NAME }} + AZURE_SIGN_ENDPOINT: ${{ secrets.AZURE_SIGN_ENDPOINT }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + MACOS_CSC_KEY_PASSWORD: ${{ secrets.MACOS_CSC_KEY_PASSWORD }} + MACOS_CSC_LINK: ${{ secrets.MACOS_CSC_LINK }} + POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} + POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} + SENTRY_DSN_MOBILE: ${{ secrets.SENTRY_DSN_MOBILE }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - run: node .github/scripts/release-inputs.cjs --phase sign --platform desktop + - run: node .github/scripts/release-inputs.cjs --phase sign --platform mobile + - if: ${{ inputs.upload }} + run: node .github/scripts/release-inputs.cjs --phase upload --platform mobile + + render: + name: Render ${{ matrix.brandId }} + if: ${{ inputs.build }} + needs: [prepare, render-inputs] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: true + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + + - run: pnpm install --frozen-lockfile + + - name: Render desktop bundle and identity + uses: ./.github/actions/render-release-config + with: + app: desktop + brand-artifacts: true + publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} + publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + revision: ${{ vars.CONFIG_RELEASE_REVISION }} + keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} + release-manifest: ${{ toJSON(matrix.releaseManifests.desktop) }} + + - name: Render mobile bundles and identities + uses: ./.github/actions/render-release-config + with: + app: mobile + publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} + publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + revision: ${{ vars.CONFIG_RELEASE_REVISION }} + keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} + release-manifest-ios: ${{ toJSON(matrix.releaseManifests.ios) }} + release-manifest-android: ${{ toJSON(matrix.releaseManifests.android) }} + + - name: Stage immutable release inputs + env: + COMPLIANCE_ANDROID: ${{ toJSON(matrix.compliance.android) }} + COMPLIANCE_DESKTOP: ${{ toJSON(matrix.compliance.desktop) }} + COMPLIANCE_IOS: ${{ toJSON(matrix.compliance.ios) }} + MANIFEST_ANDROID: ${{ toJSON(matrix.releaseManifests.android) }} + MANIFEST_DESKTOP: ${{ toJSON(matrix.releaseManifests.desktop) }} + MANIFEST_IOS: ${{ toJSON(matrix.releaseManifests.ios) }} + MOBILE_DISTRIBUTION: ${{ toJSON(matrix.distribution.mobile) }} + run: | + set -euo pipefail + mkdir release-inputs + cp "$RUNNER_TEMP/config-render-desktop/source/packages/config-structural/brands.manifest.yaml" release-inputs/ + printf '%s' "$MANIFEST_DESKTOP" > release-inputs/release-manifest.desktop.json + printf '%s' "$MANIFEST_IOS" > release-inputs/release-manifest.ios.json + printf '%s' "$MANIFEST_ANDROID" > release-inputs/release-manifest.android.json + printf '%s' "$COMPLIANCE_DESKTOP" > release-inputs/compliance.desktop.json + printf '%s' "$COMPLIANCE_IOS" > release-inputs/compliance.ios.json + printf '%s' "$COMPLIANCE_ANDROID" > release-inputs/compliance.android.json + if [ "$MOBILE_DISTRIBUTION" != null ]; then + jq -cn \ + --arg brand '${{ matrix.brandId }}' \ + --arg channel '${{ matrix.channel }}' \ + --argjson distribution "$MOBILE_DISTRIBUTION" \ + '$distribution + {brandId: $brand, channel: $channel, mobileReleaseFormatVersion: 1}' \ + > apps/mobile/generated/mobile-release.json + cp apps/mobile/generated/mobile-release.json release-inputs/ + fi + + - name: Gate rendered defaults and store compliance + run: | + set -euo pipefail + mkdir release-inputs/preflight + for platform in desktop ios android; do + printf 'validated=%s/%s\n' '${{ matrix.brandId }}' "$platform" \ + > "release-inputs/preflight/${platform}.txt" + if [ "$platform" = desktop ]; then + bundle=apps/desktop/generated/config-build-bundle.json + identity=apps/desktop/generated/brand-identity.json + else + bundle="apps/mobile/src/runtime/config/bundled.generated.${platform}.ts" + identity="apps/mobile/generated/brand-identity.${platform}.json" + fi + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root release-inputs/preflight \ + --artifact "${platform}.txt" \ + --bundle "$bundle" \ + --brand-identity "$identity" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --release-manifest "release-inputs/release-manifest.${platform}.json" \ + --compliance "release-inputs/compliance.${platform}.json" \ + --out "${platform}.provenance.json" + done + + - name: Upload isolated rendered inputs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: brand-render-${{ matrix.brandId }} + path: | + apps/desktop/generated + apps/mobile/generated + apps/mobile/src/runtime/config/bundled.generated.ios.ts + apps/mobile/src/runtime/config/bundled.generated.android.ts + release-inputs + if-no-files-found: error + retention-days: 1 + + desktop: + name: Desktop ${{ matrix.brandId }} + if: ${{ inputs.build && !cancelled() && needs.render.result == 'success' && (needs.signing-inputs.result == 'success' || needs.signing-inputs.result == 'skipped') }} + needs: [prepare, render, signing-inputs] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + uses: ./.github/workflows/build-desktop.yml + secrets: inherit + permissions: + contents: read + id-token: write + with: + ref: ${{ inputs.ref }} + sign: ${{ inputs.sign }} + brand_id: ${{ matrix.brandId }} + rendered_artifact: brand-render-${{ matrix.brandId }} + update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} + + mobile-validation: + name: Mobile validation ${{ matrix.brandId }} + if: ${{ inputs.build && !inputs.sign && !cancelled() && needs.render.result == 'success' }} + needs: [prepare, render] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: ./.github/actions/setup-eas + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - name: Verify production Hermes exports + run: pnpm -F @linkcode/mobile smoke:export + - name: Verify credential-free native generation + working-directory: apps/mobile + run: | + CI=1 EXPO_NO_TELEMETRY=1 pnpm exec expo prebuild --clean --no-install --platform android + test -f android/app/build.gradle + rm -rf android + CI=1 EXPO_NO_TELEMETRY=1 pnpm exec expo prebuild --clean --no-install --platform ios + test -f ios/Podfile + - name: Record isolated validation evidence + run: | + mkdir -p "release-validation/${{ matrix.brandId }}" + for platform in ios android; do + printf '%s=production-hermes+prebuild\n' "$platform" \ + > "release-validation/${{ matrix.brandId }}/validation.${platform}.txt" + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "release-validation/${{ matrix.brandId }}" \ + --artifact "validation.${platform}.txt" \ + --bundle "apps/mobile/src/runtime/config/bundled.generated.${platform}.ts" \ + --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --release-manifest "release-inputs/release-manifest.${platform}.json" \ + --compliance "release-inputs/compliance.${platform}.json" \ + --out "release-provenance.${platform}.json" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: brand-validation-${{ matrix.brandId }} + path: release-validation/${{ matrix.brandId }} + if-no-files-found: error + retention-days: 7 + + mobile: + name: Mobile ${{ matrix.brandId }} + if: ${{ inputs.sign && !cancelled() && needs.render.result == 'success' && needs.signing-inputs.result == 'success' }} + needs: [prepare, render, signing-inputs] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + uses: ./.github/workflows/build-mobile.yml + secrets: inherit + with: + ref: ${{ inputs.ref }} + brand_id: ${{ matrix.brandId }} + rendered_artifact: brand-render-${{ matrix.brandId }} + submit: false From 24884d52c59954d1c8728ff9be4eac2eab1034e7 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:58:35 +0000 Subject: [PATCH 11/21] feat(release): gate matrix publication Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- .github/workflows/release-brand-matrix.yml | 218 +++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 021de2a87..b77e6f585 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -331,3 +331,221 @@ jobs: brand_id: ${{ matrix.brandId }} rendered_artifact: brand-render-${{ matrix.brandId }} submit: false + + publish-preflight: + name: Publish preflight ${{ matrix.brandId }} + if: ${{ inputs.upload && !cancelled() && needs.desktop.result == 'success' && needs.mobile.result == 'success' }} + needs: [prepare, desktop, mobile] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} + R2_ACCOUNT_ID: ${{ secrets[format('{0}_R2_ACCOUNT_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} + R2_SECRET_ACCESS_KEY: ${{ secrets[format('{0}_R2_SECRET_ACCESS_KEY', matrix.distribution.desktop.credentialSecretPrefix)] }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Validate every upload credential + run: | + set -euo pipefail + node .github/scripts/release-inputs.cjs --phase upload --platform desktop + node .github/scripts/release-inputs.cjs --phase upload --platform mobile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: desktop-${{ matrix.brandId }}-* + merge-multiple: true + path: publish-preflight/${{ matrix.brandId }}/desktop + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: mobile-${{ matrix.brandId }}-* + merge-multiple: true + path: publish-preflight/${{ matrix.brandId }}/mobile + - name: Re-hash every signed artifact and immutable binding + run: | + set -euo pipefail + for runner_platform in mac win linux; do + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "publish-preflight/${{ matrix.brandId }}/desktop" \ + --verify "publish-preflight/${{ matrix.brandId }}/desktop/release-provenance.${runner_platform}.json" \ + --bundle apps/desktop/generated/config-build-bundle.json \ + --brand-identity apps/desktop/generated/brand-identity.json \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --expected-brand '${{ matrix.brandId }}' \ + --expected-platform desktop \ + --release-manifest release-inputs/release-manifest.desktop.json \ + --signed + done + for platform in ios android; do + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "publish-preflight/${{ matrix.brandId }}/mobile" \ + --verify "publish-preflight/${{ matrix.brandId }}/mobile/release-provenance.${platform}.json" \ + --bundle "apps/mobile/src/runtime/config/bundled.generated.${platform}.ts" \ + --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --expected-brand '${{ matrix.brandId }}' \ + --expected-platform "$platform" \ + --release-manifest "release-inputs/release-manifest.${platform}.json" \ + --signed + done + + publish-mobile: + name: Publish mobile ${{ matrix.brandId }} + if: ${{ inputs.upload && !cancelled() && needs.desktop.result == 'success' && needs.mobile.result == 'success' && needs.publish-preflight.result == 'success' }} + needs: [prepare, desktop, mobile, publish-preflight] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: ./.github/actions/setup-eas + - run: pnpm install --frozen-lockfile + - name: Validate upload inputs + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: node .github/scripts/release-inputs.cjs --phase upload --platform mobile + - name: Fetch brand release inputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - name: Fetch signed mobile artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: mobile-${{ matrix.brandId }}-* + merge-multiple: true + path: artifacts/${{ matrix.brandId }} + - name: Require provenance and apply internal-store destinations + run: | + set -euo pipefail + for platform in ios android; do + test -s "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "artifacts/${{ matrix.brandId }}" \ + --verify "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" \ + --bundle "apps/mobile/src/runtime/config/bundled.generated.${platform}.ts" \ + --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --expected-brand '${{ matrix.brandId }}' \ + --expected-platform "$platform" \ + --release-manifest "release-inputs/release-manifest.${platform}.json" \ + --signed + done + release=release-inputs/mobile-release.json + asc_app_id="$(jq -er .ios.ascAppId "$release")" + apple_team_id="$(jq -er .ios.appleTeamId "$release")" + android_track="$(jq -er .android.track "$release")" + ios_bundle_id="$(jq -er .applicationId apps/mobile/generated/brand-identity.ios.json)" + tmp="$(mktemp)" + jq --arg asc "$asc_app_id" --arg team "$apple_team_id" --arg track "$android_track" --arg bundle "$ios_bundle_id" \ + '.submit.production.ios.ascAppId = $asc + | .submit.production.ios.appleTeamId = $team + | .submit.production.ios.bundleIdentifier = $bundle + | del(.submit.production.ios.metadataPath) + | .submit.production.android.track = $track' \ + apps/mobile/eas.json > "$tmp" + mv "$tmp" apps/mobile/eas.json + - name: Submit only to TestFlight and Play internal + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + set -euo pipefail + eas submit --platform ios --profile production \ + --path "$GITHUB_WORKSPACE/artifacts/${{ matrix.brandId }}/linkcode-ios.ipa" \ + --non-interactive --wait + eas submit --platform android --profile production \ + --path "$GITHUB_WORKSPACE/artifacts/${{ matrix.brandId }}/linkcode-android.aab" \ + --non-interactive --wait + + publish-desktop: + name: Publish desktop ${{ matrix.brandId }} + if: ${{ inputs.upload && !cancelled() && needs.desktop.result == 'success' && needs.mobile.result == 'success' && needs.publish-preflight.result == 'success' }} + needs: [prepare, desktop, mobile, publish-preflight] + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + env: + AWS_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} + AWS_SECRET_ACCESS_KEY: ${{ secrets[format('{0}_R2_SECRET_ACCESS_KEY', matrix.distribution.desktop.credentialSecretPrefix)] }} + R2_ACCOUNT_ID: ${{ secrets[format('{0}_R2_ACCOUNT_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.ref }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: pnpm install --frozen-lockfile + - name: Validate upload inputs + env: + R2_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }} + run: node .github/scripts/release-inputs.cjs --phase upload --platform desktop + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: desktop-${{ matrix.brandId }}-* + merge-multiple: true + path: artifacts/${{ matrix.brandId }} + - name: Require per-platform provenance + run: | + set -euo pipefail + for platform in mac win linux; do + test -s "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "artifacts/${{ matrix.brandId }}" \ + --verify "artifacts/${{ matrix.brandId }}/release-provenance.${platform}.json" \ + --bundle apps/desktop/generated/config-build-bundle.json \ + --brand-identity apps/desktop/generated/brand-identity.json \ + --brand-manifest release-inputs/brands.manifest.yaml \ + --client-git-sha '${{ inputs.ref }}' \ + --expected-brand '${{ matrix.brandId }}' \ + --expected-platform desktop \ + --release-manifest release-inputs/release-manifest.desktop.json \ + --signed + done + - name: Upload only this brand prefix + env: + AWS_REGION: auto + AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED + AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + run: | + aws s3 sync "artifacts/${{ matrix.brandId }}/" \ + "s3://${{ matrix.distribution.desktop.r2Bucket }}/${{ matrix.distribution.desktop.r2Prefix }}/" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --no-progress From c46ffe2f698e9614b537486198a47ff00aa694bd Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 7 Aug 2026 09:58:35 +0000 Subject: [PATCH 12/21] docs(release): document brand matrix contract Amp-Thread-ID: https://ampcode.com/threads/T-019fdb43-f740-72e2-ba5a-20151d8defab --- docs/ENVIRONMENT.md | 8 +++- docs/RELEASE.md | 90 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 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`. From 4f4fb0b114acd79aa784899d3f8217c8105911f1 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Sat, 8 Aug 2026 09:52:58 +0000 Subject: [PATCH 13/21] fix(release): harden brand matrix trust --- .github/scripts/brand-matrix.cjs | 12 +-- .github/scripts/brand-matrix.test.mjs | 19 ++++- .github/workflows/build-desktop.yml | 9 ++- .github/workflows/build-mobile.yml | 7 ++ .github/workflows/release-brand-matrix.yml | 78 +++++++++++++++++-- docs/ENVIRONMENT.md | 1 - docs/RELEASE.md | 42 +++++----- .../node/__tests__/release-artifact.test.ts | 37 +++++++++ .../common/src/node/release-artifact-cli.mts | 15 +++- .../common/src/node/release-artifact.ts | 30 +++++++ 10 files changed, 210 insertions(+), 40 deletions(-) diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs index 7710056e3..1f135ef13 100644 --- a/.github/scripts/brand-matrix.cjs +++ b/.github/scripts/brand-matrix.cjs @@ -294,6 +294,7 @@ function strictBoolean(value, name) { } function runCli(argv = process.argv.slice(2), env = process.env) { + const { createHash } = require('node:crypto'); const { appendFileSync, readFileSync } = require('node:fs'); const { parseArgs } = require('node:util'); const { values } = parseArgs({ @@ -306,15 +307,14 @@ function runCli(argv = process.argv.slice(2), env = process.env) { }, strict: true, }); - const text = values['matrix-file'] - ? readFileSync(values['matrix-file'], 'utf8') - : env.BRAND_BUILD_MATRIX; - if (!text) fail('BRAND_BUILD_MATRIX', 'must be set or supplied with --matrix-file'); + if (!values['matrix-file']) fail('--matrix-file', 'is required'); + const bytes = readFileSync(values['matrix-file']); + const text = bytes.toString('utf8'); let matrix; try { matrix = JSON.parse(text); } catch { - fail('BRAND_BUILD_MATRIX', 'must be valid JSON'); + fail('--matrix-file', 'must contain valid JSON'); } const plan = buildMatrixPlan(matrix, { build: strictBoolean(values.build, '--build'), @@ -323,6 +323,7 @@ function runCli(argv = process.argv.slice(2), env = process.env) { }); const outputs = [ `brands=${JSON.stringify(plan.brands)}`, + `delivery_descriptor_sha256=${createHash('sha256').update(bytes).digest('hex')}`, `targets=${JSON.stringify(plan.targets)}`, ]; if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${outputs.join('\n')}\n`); @@ -338,4 +339,5 @@ module.exports = { PLATFORMS, buildMatrixPlan, parseBrandBuildMatrix, + runCli, }; diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index ab34d3f77..9b74dc377 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -1,7 +1,11 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import matrixModule from './brand-matrix.cjs'; -const { buildMatrixPlan, parseBrandBuildMatrix } = matrixModule; +const { buildMatrixPlan, parseBrandBuildMatrix, runCli } = matrixModule; const RE_WRONG_BRAND = /must target acme\/ios\/canary/; const RE_WRONG_PLATFORM = /must target acme\/android\/canary/; const RE_UNCHECKED = /noExecutableCode: must be true/; @@ -208,4 +212,17 @@ describe('parseBrandBuildMatrix', () => { divergent.brands[0].releaseManifests.ios.sourceGitSha = gitSha('f'); expect(() => parseBrandBuildMatrix(divergent)).toThrow(RE_DIVERGENT_SOURCE); }); + + it('emits the digest of the exact matrix-file bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'brand-matrix-')); + const matrixPath = join(root, 'matrix.json'); + const outputPath = join(root, 'github-output'); + const bytes = `${JSON.stringify(matrix(brand()))}\n`; + await writeFile(matrixPath, bytes); + runCli(['--matrix-file', matrixPath], { GITHUB_OUTPUT: outputPath }); + const output = await readFile(outputPath, 'utf8'); + expect(output).toContain( + `delivery_descriptor_sha256=${createHash('sha256').update(bytes).digest('hex')}\n`, + ); + }); }); diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 9aa5ac1e4..1ecbe8fb8 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -7,7 +7,7 @@ name: Build Desktop on: - # Invoked by release-desktop.yml (caller must use `secrets: inherit`). + # Release-environment secrets resolve in the called jobs; callers need not forward all secrets. workflow_call: inputs: ref: @@ -25,6 +25,11 @@ on: type: string required: false default: "" + delivery_descriptor_sha256: + description: Expected SHA-256 of the committed brand delivery matrix + type: string + required: false + default: "" rendered_artifact: description: Pre-rendered brand/config artifact from the matrix workflow type: string @@ -302,6 +307,8 @@ jobs: --brand-identity apps/desktop/generated/brand-identity.json \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ inputs.delivery_descriptor_sha256 }}' \ --release-manifest release-inputs/release-manifest.desktop.json \ --compliance release-inputs/compliance.desktop.json \ --out "release-provenance.${{ matrix.platform }}.json" \ diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 1ad5f5f7e..dbf976245 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -16,6 +16,11 @@ on: type: string required: false default: "" + delivery_descriptor_sha256: + description: Expected SHA-256 of the committed brand delivery matrix + type: string + required: false + default: "" rendered_artifact: description: Pre-rendered brand/config artifact from the matrix workflow type: string @@ -217,6 +222,8 @@ jobs: --brand-identity "apps/mobile/generated/brand-identity.${{ matrix.platform }}.json" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ inputs.delivery_descriptor_sha256 }}' \ --release-manifest "release-inputs/release-manifest.${{ matrix.platform }}.json" \ --compliance "release-inputs/compliance.${{ matrix.platform }}.json" \ --out "release-provenance.${{ matrix.platform }}.json" \ diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index b77e6f585..18bb0c5e3 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -7,8 +7,13 @@ on: description: Exact client ref to build type: string required: true + matrix_file: + description: Reviewed matrix under .github/release/brand-matrices; required for builds + type: string + required: false + default: "" matrix_json: - description: Matrix JSON; empty reads vars.BRAND_BUILD_MATRIX + description: Uncommitted matrix JSON for plan validation only type: string required: false default: "" @@ -41,11 +46,14 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} outputs: brands: ${{ steps.matrix.outputs.brands }} + delivery_descriptor_sha256: ${{ steps.matrix.outputs.delivery_descriptor_sha256 }} targets: ${{ steps.matrix.outputs.targets }} steps: - name: Validate request shape env: CLIENT_REF: ${{ inputs.ref }} + MATRIX_FILE: ${{ inputs.matrix_file }} + MATRIX_JSON: ${{ inputs.matrix_json }} WORKFLOW_SHA: ${{ github.sha }} run: | set -euo pipefail @@ -57,27 +65,66 @@ jobs: echo "::error::ref must equal github.sha so workflow code, release-environment policy, and built client use one commit" exit 1 fi - if ${{ (inputs.sign && !inputs.build) || (inputs.upload && !inputs.sign) || (inputs.matrix_json != '' && inputs.build) }}; then - echo "::error::sign requires build=true; upload requires sign=true; matrix_json is plan-only and build requests must use the reviewed BRAND_BUILD_MATRIX variable" + if ${{ (inputs.sign && !inputs.build) || (inputs.upload && !inputs.sign) }}; then + echo "::error::sign requires build=true and upload requires sign=true" + exit 1 + fi + if { [ -z "$MATRIX_FILE" ] && [ -z "$MATRIX_JSON" ]; } || { [ -n "$MATRIX_FILE" ] && [ -n "$MATRIX_JSON" ]; }; then + echo "::error::provide exactly one of matrix_file or matrix_json" + exit 1 + fi + if [ -n "$MATRIX_JSON" ] && ${{ inputs.build }}; then + echo "::error::matrix_json is plan-only; builds require a reviewed matrix_file" + exit 1 + fi + if [ -n "$MATRIX_FILE" ] && [[ ! "$MATRIX_FILE" =~ ^\.github/release/brand-matrices/[a-z0-9][a-z0-9._-]*\.json$ ]]; then + echo "::error::matrix_file must be a JSON file directly under .github/release/brand-matrices" exit 1 fi - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref }} + fetch-depth: 0 - - name: Verify exact client checkout + - name: Verify trusted client checkout env: CLIENT_REF: ${{ inputs.ref }} + MATRIX_FILE: ${{ inputs.matrix_file }} run: | set -euo pipefail test "$(git rev-parse HEAD)" = "$CLIENT_REF" + if ! git merge-base --is-ancestor "$CLIENT_REF" refs/remotes/origin/master; then + echo "::error::ref must be a commit already reachable from protected master" + exit 1 + fi + if [ -n "$MATRIX_FILE" ]; then + entry="$(git ls-tree "$CLIENT_REF" -- "$MATRIX_FILE")" + if [[ "$entry" != '100644 blob '* ]]; then + echo "::error::matrix_file must be a regular file committed at ref" + exit 1 + fi + git cat-file blob "$CLIENT_REF:$MATRIX_FILE" > "$RUNNER_TEMP/committed-brand-matrix.json" + fi - name: Build strict matrix plan id: matrix env: - BRAND_BUILD_MATRIX: ${{ inputs.matrix_json || vars.BRAND_BUILD_MATRIX }} - run: node .github/scripts/brand-matrix.cjs --build "${{ inputs.build }}" --sign "${{ inputs.sign }}" --upload "${{ inputs.upload }}" + MATRIX_FILE: ${{ inputs.matrix_file }} + MATRIX_JSON: ${{ inputs.matrix_json }} + run: | + set -euo pipefail + if [ -n "$MATRIX_JSON" ]; then + matrix_file="$RUNNER_TEMP/brand-build-matrix.json" + printf '%s' "$MATRIX_JSON" > "$matrix_file" + else + matrix_file="$RUNNER_TEMP/committed-brand-matrix.json" + fi + node .github/scripts/brand-matrix.cjs \ + --matrix-file "$matrix_file" \ + --build "${{ inputs.build }}" \ + --sign "${{ inputs.sign }}" \ + --upload "${{ inputs.upload }}" render-inputs: name: Validate immutable render inputs @@ -183,6 +230,8 @@ jobs: COMPLIANCE_ANDROID: ${{ toJSON(matrix.compliance.android) }} COMPLIANCE_DESKTOP: ${{ toJSON(matrix.compliance.desktop) }} COMPLIANCE_IOS: ${{ toJSON(matrix.compliance.ios) }} + CLIENT_REF: ${{ inputs.ref }} + MATRIX_FILE: ${{ inputs.matrix_file }} MANIFEST_ANDROID: ${{ toJSON(matrix.releaseManifests.android) }} MANIFEST_DESKTOP: ${{ toJSON(matrix.releaseManifests.desktop) }} MANIFEST_IOS: ${{ toJSON(matrix.releaseManifests.ios) }} @@ -190,6 +239,7 @@ jobs: run: | set -euo pipefail mkdir release-inputs + git cat-file blob "$CLIENT_REF:$MATRIX_FILE" > release-inputs/brand-build-matrix.json cp "$RUNNER_TEMP/config-render-desktop/source/packages/config-structural/brands.manifest.yaml" release-inputs/ printf '%s' "$MANIFEST_DESKTOP" > release-inputs/release-manifest.desktop.json printf '%s' "$MANIFEST_IOS" > release-inputs/release-manifest.ios.json @@ -228,6 +278,8 @@ jobs: --brand-identity "$identity" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ --compliance "release-inputs/compliance.${platform}.json" \ --out "${platform}.provenance.json" @@ -254,7 +306,6 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} uses: ./.github/workflows/build-desktop.yml - secrets: inherit permissions: contents: read id-token: write @@ -262,6 +313,7 @@ jobs: ref: ${{ inputs.ref }} sign: ${{ inputs.sign }} brand_id: ${{ matrix.brandId }} + delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} @@ -306,6 +358,8 @@ jobs: --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ --compliance "release-inputs/compliance.${platform}.json" \ --out "release-provenance.${platform}.json" @@ -325,10 +379,10 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} uses: ./.github/workflows/build-mobile.yml - secrets: inherit with: ref: ${{ inputs.ref }} brand_id: ${{ matrix.brandId }} + delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} rendered_artifact: brand-render-${{ matrix.brandId }} submit: false @@ -389,6 +443,8 @@ jobs: --brand-identity apps/desktop/generated/brand-identity.json \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ --expected-platform desktop \ --release-manifest release-inputs/release-manifest.desktop.json \ @@ -402,6 +458,8 @@ jobs: --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ --expected-platform "$platform" \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ @@ -450,6 +508,8 @@ jobs: --brand-identity "apps/mobile/generated/brand-identity.${platform}.json" \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ --expected-platform "$platform" \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ @@ -534,6 +594,8 @@ jobs: --brand-identity apps/desktop/generated/brand-identity.json \ --brand-manifest release-inputs/brands.manifest.yaml \ --client-git-sha '${{ inputs.ref }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ --expected-platform desktop \ --release-manifest release-inputs/release-manifest.desktop.json \ diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 677e0e7f5..18b34708b 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -110,7 +110,6 @@ client configuration or new build. | `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` | `apps/desktop/scripts/stage-sidecar.mts` | `aarch64-linux-gnu-gcc` for the linux-arm64 sidecar cross-build. | | `NODE_OPTIONS` | `.github/workflows/ci.yml` | `--max-old-space-size=4096` for every CI job. | | `POSTHOG_HOST` | desktop/mobile build workflows | Organization Actions variable mapped to the platform-specific PostHog host for production bundles. | -| `BRAND_BUILD_MATRIX` | `release-brand-matrix.yml` | Repository Actions var containing the reviewed strict brand × platform JSON matrix. A manual `matrix_json` input may replace it only for plan validation; builds reject that override. It contains only public release bindings and destination identifiers, never credentials. | | `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars. Repository name plus exact revision/public-keyring JSON bytes; release manifests digest-bind the JSON inputs. | ## Release-only secrets diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 1d9a01187..68d241d2f 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -103,14 +103,16 @@ Enforcement: `LINKCODE_REQUIRE_CONFIG_BUNDLE=1` (set for signed desktop builds) ## Brand × platform release matrix `release-brand-matrix.yml` is manually dispatched against the exact lowercase 40-hex commit that -loaded the workflow (`inputs.ref == github.sha`), so protected workflow code, environment ref policy, -local actions, and client source have one trust root. Plan-only requests -may supply `matrix_json`; every build must use the reviewed repository Actions variable -`BRAND_BUILD_MATRIX`. `build`, `sign`, and `upload` are independent, monotonic gates: signing requires a build, -and upload requires signing. The default (`false` for all three) only validates the matrix and -needs no credential. `build: true, sign: false` renders one immutable target set per brand, creates -unsigned Desktop packages, and validates production-Hermes exports plus iOS/Android prebuilds. -Nothing is signed or submitted in that path. +loaded the workflow (`inputs.ref == github.sha`) and rejects commits not already reachable from +`master`, so protected workflow code, environment ref policy, local actions, and client source have +one trust root. Plan-only requests may supply `matrix_json`; every build instead requires a +`matrix_file` directly under `.github/release/brand-matrices/` in that same reviewed commit. Add or +update the complete matrix through a PR before dispatching a release; Actions variables are not a +release-plan authority. `build`, `sign`, and `upload` are independent, monotonic gates: signing +requires a build, and upload requires signing. The default (`false` for all three) only validates +the selected matrix and needs no credential. `build: true, sign: false` renders one immutable target +set per brand, creates unsigned Desktop packages, and validates production-Hermes exports plus +iOS/Android prebuilds. Nothing is signed or submitted in that path. The JSON root contains `brandBuildMatrixVersion: 1` and a non-empty `brands` array. Every brand has exactly `brandId`, `channel`, `releaseManifests`, `compliance`, and `distribution`: @@ -139,8 +141,9 @@ team/App Store Connect IDs, and the internal Android track; all other fields are Every uploaded build has a canonical `release-provenance..json`. Each listed artifact is bound by its own SHA-256 and size to the exact `brands.manifest.yaml` SHA-256, config revision ID, canonical bundled-defaults SHA-256, config snapshot SHA-256, source/publisher commits, and release -manifest SHA-256, while the sidecar also records the exact client commit. Publish jobs re-hash the -artifacts and all immutable inputs before upload. The sidecar is written with create-only semantics after all checks pass. +manifest SHA-256, while the sidecar also records the exact client commit and committed matrix-file +SHA-256. Publish jobs re-hash the artifacts and all immutable inputs before upload. The sidecar is +written with create-only semantics after all checks pass. Brand render jobs, artifact names, runner workspaces, validation roots, credential pairs, and R2 prefixes are separate. Render jobs preserve successful sibling evidence when another row fails, while aggregate build and publish-preflight jobs require every brand's five provenance sidecars and @@ -148,14 +151,12 @@ upload inputs before any store submission or R2 upload can begin. ### Required Actions configuration and least privilege -Secrets and render vars below are read only from the protected `release` environment; -`BRAND_BUILD_MATRIX` is a repository Actions var because it contains no credential and the -credential-free plan job does not enter an environment. The scripts report every missing name and -never default a signing or upload input: +Secrets and render vars below are read only from the protected `release` environment. The scripts +report every missing name and never default a signing or upload input: -- Vars: `BRAND_BUILD_MATRIX`, `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, - `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. Revision/keyring values are exact JSON bytes already digest-pinned by each - release manifest. +- Vars: `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS`, and + `POSTHOG_HOST`. Revision/keyring values are exact JSON bytes already digest-pinned by each release + manifest. - Config source: secret `CONFIG_PUBLISHER_TOKEN`, a fine-grained token with **Contents: read** only on `CONFIG_PUBLISHER_REPO`; no write or organization scope. - macOS Desktop: `MACOS_CSC_LINK`, `MACOS_CSC_KEY_PASSWORD`, `APPLE_API_KEY_BASE64`, @@ -180,10 +181,9 @@ never default a signing or upload input: brand prefix or permit bucket/account administration. `_R2_ACCOUNT_ID` is exactly the lowercase 32-hex Cloudflare account ID; URL-like or otherwise malformed values fail before AWS CLI runs. -Do not store private signing material, access tokens, or service-account JSON in -`BRAND_BUILD_MATRIX`, repository files, artifacts, or Actions vars. Protect the `release` -environment with required reviewers and exact deployment ref rules before enabling `sign` or -`upload`. +Do not store private signing material, access tokens, or service-account JSON in the committed +matrix, repository files, artifacts, or Actions vars. Protect the `release` environment with +required reviewers and exact deployment ref rules before enabling `sign` or `upload`. ## Packaging inputs (staging & version pins) diff --git a/packages/foundation/common/src/node/__tests__/release-artifact.test.ts b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts index f2aba0d7b..67950baa7 100644 --- a/packages/foundation/common/src/node/__tests__/release-artifact.test.ts +++ b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts @@ -49,6 +49,10 @@ const releaseManifest = { const RE_SHA256 = /^[0-9a-f]{64}$/; const RE_EXISTS = /EEXIST/; const clientGitSha = 'f'.repeat(40); +const deliveryDescriptorBytes = new TextEncoder().encode('{"brand":"acme"}'); +const expectedDeliveryDescriptorSha256 = createHash('sha256') + .update(deliveryDescriptorBytes) + .digest('hex'); describe('release artifact provenance', () => { it('binds each isolated artifact to the manifest, revision, and defaults digests', async () => { @@ -62,6 +66,8 @@ describe('release artifact provenance', () => { bundle, clientGitSha, compliance, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, releaseManifest, releaseManifestBytes: new TextEncoder().encode('{}'), signed: false, @@ -78,6 +84,7 @@ describe('release artifact provenance', () => { }); expect(provenance.artifacts[0]?.brandManifestSha256).toMatch(RE_SHA256); expect(provenance.artifacts[0]?.defaultsSha256).toMatch(RE_SHA256); + expect(provenance.deliveryDescriptorSha256).toBe(expectedDeliveryDescriptorSha256); expect(provenance.artifacts[0]?.brandManifestSha256).toBe( createHash('sha256').update('brands: [acme]').digest('hex'), ); @@ -209,6 +216,8 @@ describe('release artifact provenance', () => { bundle, clientGitSha, compliance, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, releaseManifest, releaseManifestBytes: new Uint8Array(), signed: false, @@ -234,6 +243,7 @@ describe('release artifact provenance', () => { channel: 'canary', clientGitSha, configSnapshotSha256: 'a'.repeat(64), + deliveryDescriptorSha256: 'e'.repeat(64), platform: 'ios', publisherGitSha: 'b'.repeat(40), releaseArtifactProvenanceVersion: 1, @@ -261,6 +271,8 @@ describe('release artifact provenance', () => { bundle, clientGitSha, compliance, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, releaseManifest, releaseManifestBytes: new TextEncoder().encode('{}'), signed: true, @@ -273,6 +285,8 @@ describe('release artifact provenance', () => { brandId: bundle.brandId, bundle, clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, platform: bundle.platform, provenance, releaseManifest, @@ -280,6 +294,23 @@ describe('release artifact provenance', () => { signed: true, }), ).resolves.toStrictEqual(provenance); + await expect( + verifyReleaseArtifactProvenance({ + artifactRoot: root, + brandIdentity: identity, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + brandId: bundle.brandId, + bundle, + clientGitSha, + deliveryDescriptorBytes: new TextEncoder().encode('{"brand":"zenith"}'), + expectedDeliveryDescriptorSha256, + platform: bundle.platform, + provenance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: true, + }), + ).rejects.toThrow('reviewed release matrix'); await expect( verifyReleaseArtifactProvenance({ artifactRoot: root, @@ -288,6 +319,8 @@ describe('release artifact provenance', () => { brandId: 'zenith', bundle, clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, platform: bundle.platform, provenance, releaseManifest, @@ -303,6 +336,8 @@ describe('release artifact provenance', () => { brandId: bundle.brandId, bundle, clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, platform: bundle.platform, provenance, releaseManifest, @@ -319,6 +354,8 @@ describe('release artifact provenance', () => { brandId: bundle.brandId, bundle, clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, platform: bundle.platform, provenance, releaseManifest, diff --git a/packages/foundation/common/src/node/release-artifact-cli.mts b/packages/foundation/common/src/node/release-artifact-cli.mts index c3c519d22..e1285ef75 100644 --- a/packages/foundation/common/src/node/release-artifact-cli.mts +++ b/packages/foundation/common/src/node/release-artifact-cli.mts @@ -12,10 +12,13 @@ import { const USAGE = `Usage: release-artifact --artifact-root --artifact [--artifact ...] --bundle --brand-identity --brand-manifest - --release-manifest --compliance --client-git-sha --out [--signed] + --delivery-descriptor --release-manifest --compliance + --expected-delivery-sha256 --client-git-sha --out [--signed] release-artifact --artifact-root --verify - --bundle --brand-identity --brand-manifest --release-manifest - --client-git-sha --expected-brand --expected-platform [--signed]`; + --bundle --brand-identity --brand-manifest + --delivery-descriptor --release-manifest --client-git-sha + --expected-brand --expected-delivery-sha256 + --expected-platform [--signed]`; function bail(message: string): never { throw new TypeError(`release-artifact: ${message}\n\n${USAGE}`); @@ -105,7 +108,9 @@ async function main(): Promise { bundle: { type: 'string' }, 'client-git-sha': { type: 'string' }, compliance: { type: 'string' }, + 'delivery-descriptor': { type: 'string' }, 'expected-brand': { type: 'string' }, + 'expected-delivery-sha256': { type: 'string' }, 'expected-platform': { type: 'string' }, out: { type: 'string' }, 'release-manifest': { type: 'string' }, @@ -134,6 +139,8 @@ async function main(): Promise { brandId: required('expected-brand'), bundle: parsed.bundle, clientGitSha: required('client-git-sha'), + deliveryDescriptorBytes: await readFile(required('delivery-descriptor')), + expectedDeliveryDescriptorSha256: required('expected-delivery-sha256'), platform: required('expected-platform'), provenance: input.value, releaseManifest: releaseManifest(releaseInput.value), @@ -159,6 +166,8 @@ async function main(): Promise { bundle: parsed.bundle, clientGitSha: required('client-git-sha'), compliance: compliance(complianceInput.value), + deliveryDescriptorBytes: await readFile(required('delivery-descriptor')), + expectedDeliveryDescriptorSha256: required('expected-delivery-sha256'), releaseManifest: releaseManifest(releaseInput.value), releaseManifestBytes: releaseInput.bytes, signed: values.signed, diff --git a/packages/foundation/common/src/node/release-artifact.ts b/packages/foundation/common/src/node/release-artifact.ts index b14cd042b..025c13145 100644 --- a/packages/foundation/common/src/node/release-artifact.ts +++ b/packages/foundation/common/src/node/release-artifact.ts @@ -40,6 +40,7 @@ export interface ReleaseArtifactProvenance { readonly channel: string; readonly clientGitSha: string; readonly configSnapshotSha256: string; + readonly deliveryDescriptorSha256: string; readonly platform: string; readonly publisherGitSha: string; readonly releaseArtifactProvenanceVersion: 1; @@ -55,6 +56,17 @@ function sha256(bytes: string | Uint8Array): string { return createHash('sha256').update(bytes).digest('hex'); } +function deliveryDescriptorSha256(bytes: Uint8Array, expected: string): string { + if (!RE_SHA256.test(expected)) { + throw new TypeError('expectedDeliveryDescriptorSha256 must be a lowercase SHA-256 digest'); + } + const actual = sha256(bytes); + if (actual !== expected) { + throw new Error('delivery descriptor does not match the reviewed release matrix'); + } + return actual; +} + function assertReleaseBinding(bundle: ConfigBuildBundle, manifest: ReleaseManifestBinding): void { const checks = [ ['brandId', bundle.brandId, manifest.brandId], @@ -105,6 +117,8 @@ export async function createReleaseArtifactProvenance(input: { readonly bundle: ConfigBuildBundle; readonly clientGitSha: string; readonly compliance: StoreComplianceDeclaration; + readonly deliveryDescriptorBytes: Uint8Array; + readonly expectedDeliveryDescriptorSha256: string; readonly releaseManifest: ReleaseManifestBinding; readonly releaseManifestBytes: Uint8Array; readonly signed: boolean; @@ -124,6 +138,10 @@ export async function createReleaseArtifactProvenance(input: { const defaults = jsonValue(configBuildBundleDefaults(input.bundle)); const defaultsSha256 = sha256(canonicalizeJson(defaults)); const brandManifestSha256 = sha256(input.brandManifestBytes); + const deliverySha256 = deliveryDescriptorSha256( + input.deliveryDescriptorBytes, + input.expectedDeliveryDescriptorSha256, + ); const files = await Promise.all( [...input.artifactPaths].sort().map((path) => artifactFile(input.artifactRoot, path)), ); @@ -138,6 +156,7 @@ export async function createReleaseArtifactProvenance(input: { channel: input.bundle.channel, clientGitSha: input.clientGitSha, configSnapshotSha256: input.bundle.snapshot.sha256, + deliveryDescriptorSha256: deliverySha256, platform: input.bundle.platform, publisherGitSha: input.releaseManifest.publisherGitSha, releaseArtifactProvenanceVersion: 1, @@ -159,6 +178,7 @@ function releaseArtifactProvenance(value: unknown): ReleaseArtifactProvenance { 'channel', 'clientGitSha', 'configSnapshotSha256', + 'deliveryDescriptorSha256', 'platform', 'publisherGitSha', 'releaseArtifactProvenanceVersion', @@ -182,6 +202,8 @@ function releaseArtifactProvenance(value: unknown): ReleaseArtifactProvenance { !RE_GIT_SHA.test(provenance.clientGitSha) || typeof provenance.configSnapshotSha256 !== 'string' || !RE_SHA256.test(provenance.configSnapshotSha256) || + typeof provenance.deliveryDescriptorSha256 !== 'string' || + !RE_SHA256.test(provenance.deliveryDescriptorSha256) || typeof provenance.releaseManifestSha256 !== 'string' || !RE_SHA256.test(provenance.releaseManifestSha256) || typeof provenance.publisherGitSha !== 'string' || @@ -251,6 +273,7 @@ function releaseArtifactProvenance(value: unknown): ReleaseArtifactProvenance { channel: provenance.channel, clientGitSha: provenance.clientGitSha, configSnapshotSha256: provenance.configSnapshotSha256, + deliveryDescriptorSha256: provenance.deliveryDescriptorSha256, platform: provenance.platform, publisherGitSha: provenance.publisherGitSha, releaseArtifactProvenanceVersion: 1, @@ -267,6 +290,8 @@ export async function verifyReleaseArtifactProvenance(input: { readonly brandId: string; readonly bundle: ConfigBuildBundle; readonly clientGitSha: string; + readonly deliveryDescriptorBytes: Uint8Array; + readonly expectedDeliveryDescriptorSha256: string; readonly platform: string; readonly provenance: unknown; readonly releaseManifest: ReleaseManifestBinding; @@ -283,6 +308,10 @@ export async function verifyReleaseArtifactProvenance(input: { const defaultsSha256 = sha256( canonicalizeJson(jsonValue(configBuildBundleDefaults(input.bundle))), ); + const deliverySha256 = deliveryDescriptorSha256( + input.deliveryDescriptorBytes, + input.expectedDeliveryDescriptorSha256, + ); if ( provenance.brandId !== input.brandId || provenance.channel !== input.bundle.channel || @@ -290,6 +319,7 @@ export async function verifyReleaseArtifactProvenance(input: { provenance.signed !== input.signed || provenance.clientGitSha !== input.clientGitSha || provenance.configSnapshotSha256 !== input.bundle.snapshot.sha256 || + provenance.deliveryDescriptorSha256 !== deliverySha256 || provenance.publisherGitSha !== input.releaseManifest.publisherGitSha || provenance.releaseManifestSha256 !== sha256(input.releaseManifestBytes) || provenance.sourceGitSha !== input.bundle.provenance.sourceGitSha From 63a3639513119fdb7e78c349b6073e1d547798ad Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 9 Aug 2026 18:59:25 +0000 Subject: [PATCH 14/21] test(release): validate credential-free brand pilot Amp-Thread-ID: https://ampcode.com/threads/T-019fe7ac-7d82-754e-a203-5d0214817d24 --- .github/scripts/brand-matrix.test.mjs | 81 +++++++++++ .github/workflows/release-brand-matrix.yml | 150 ++++++++++++++++++++- 2 files changed, 230 insertions(+), 1 deletion(-) diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index 9b74dc377..d54de09c2 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -226,3 +226,84 @@ describe('parseBrandBuildMatrix', () => { ); }); }); + +describe('release brand matrix workflow', () => { + it('keeps local runtime validation independent of provider and signing inputs', async () => { + const workflow = await readFile( + new URL('../workflows/release-brand-matrix.yml', import.meta.url), + 'utf8', + ); + const validation = workflow.slice( + workflow.indexOf(' credential-free-validation:'), + workflow.indexOf(' release-environment-preflight:'), + ); + + expect(validation).toContain('needs: prepare'); + expect(validation).toContain('xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary'); + expect(validation).toContain('pnpm -F @linkcode/mobile smoke:export'); + expect(validation).toContain('expo prebuild --clean --no-install --platform android'); + expect(validation).toContain('expo prebuild --clean --no-install --platform ios'); + expect(validation).toContain('"local-static-origin"'); + expect(validation).toContain('providerDeploymentId:null'); + expect(validation).not.toContain('environment: release'); + expect(validation).not.toContain('secrets.'); + expect(validation).not.toContain('release-environment-preflight'); + }); + + it('fails closed unless the live-pilot environment is protected', async () => { + const workflow = await readFile( + new URL('../workflows/release-brand-matrix.yml', import.meta.url), + 'utf8', + ); + const preflight = workflow.slice( + workflow.indexOf(' release-environment-preflight:'), + workflow.indexOf(' render-inputs:'), + ); + + expect(preflight).toContain('environment: release'); + expect(preflight).toContain('protection_rules'); + expect(preflight).toContain('required_reviewers'); + expect(preflight).toContain('deployment_branch_policy'); + expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); + expect(preflight).toContain('inputs.sign'); + expect(preflight).not.toContain('inputs.build'); + const renderInputs = workflow.slice( + workflow.indexOf(' render-inputs:'), + workflow.indexOf(' signing-inputs:'), + ); + expect(renderInputs).toContain('needs: prepare'); + expect(renderInputs).not.toContain('release-environment-preflight'); + const signingInputs = workflow.slice( + workflow.indexOf(' signing-inputs:'), + workflow.indexOf(' render:'), + ); + expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); + }); + + it('binds credential-free desktop recovery evidence to immutable release inputs', async () => { + const workflow = await readFile( + new URL('../workflows/release-brand-matrix.yml', import.meta.url), + 'utf8', + ); + const desktopValidation = workflow.slice( + workflow.indexOf(' desktop-validation:'), + workflow.indexOf(' mobile-validation:'), + ); + + expect(desktopValidation).toContain('inputs.build && !inputs.sign'); + expect(desktopValidation).toContain('xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary'); + expect(desktopValidation).toContain( + '54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39', + ); + expect(desktopValidation).toContain('"normal":["1","2","3","4"]'); + expect(desktopValidation).toContain('"emergency":["1","2","3"]'); + expect(desktopValidation).toContain('"kind":"local-static-origin"'); + expect(desktopValidation).toContain('"providerDeploymentId":null'); + expect(desktopValidation).toContain('--expected-delivery-sha256'); + expect(desktopValidation).toContain( + '--release-manifest release-inputs/release-manifest.desktop.json', + ); + expect(desktopValidation).toContain('--out release-provenance.desktop.json'); + expect(desktopValidation).not.toContain('environment: release'); + }); +}); diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 18bb0c5e3..208f4d6ac 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -39,6 +39,7 @@ concurrency: permissions: contents: read + deployments: read jobs: prepare: @@ -126,6 +127,84 @@ jobs: --sign "${{ inputs.sign }}" \ --upload "${{ inputs.upload }}" + credential-free-validation: + name: Credential-free runtime validation + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + 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: Exercise Electron recovery with Xvfb + run: | + set -euo pipefail + pnpm -F @linkcode/desktop exec playwright-core install-deps chromium + printf '%s %s\n' \ + '54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39' \ + 'apps/desktop/e2e/fixtures/pilot-e2e-v1.json' | sha256sum --check --strict + xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary + - name: Exercise production Hermes and native generation + env: + CI: "1" + EXPO_NO_TELEMETRY: "1" + run: | + set -euo pipefail + pnpm -F @linkcode/mobile smoke:export + pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform android + test -f apps/mobile/android/app/build.gradle + rm -rf apps/mobile/android + pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform ios + test -f apps/mobile/ios/Podfile + rm -rf apps/mobile/ios + - name: Record local-only evidence + env: + CLIENT_REF: ${{ inputs.ref }} + DELIVERY_SHA256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + run: | + mkdir -p credential-free-evidence + jq -cn \ + --arg clientGitSha "$CLIENT_REF" \ + --arg deliveryDescriptorSha256 "$DELIVERY_SHA256" \ + '{clientGitSha:$clientGitSha,deliveryDescriptorSha256:$deliveryDescriptorSha256,deploymentIdentity:{kind:"local-static-origin",providerDeploymentId:null},evidenceVersion:1,pilotFixtureSha256:"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39",runtimes:["electron+xvfb","production-hermes+android-prebuild","production-hermes+ios-prebuild"]}' \ + > credential-free-evidence/runtime-validation.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: credential-free-runtime-validation + path: credential-free-evidence + if-no-files-found: error + retention-days: 7 + + release-environment-preflight: + name: Protected live-pilot preflight + if: ${{ inputs.sign }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + environment: release + steps: + - name: Require protected nonproduction release environment + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + environment="$RUNNER_TEMP/release-environment.json" + gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" + if ! jq -e ' + ([.protection_rules[]?.type] | index("required_reviewers") != null) and + (.deployment_branch_policy != null) + ' "$environment" >/dev/null; then + echo "::error::release must require reviewers and a deployment branch policy before a live pilot" + exit 1 + fi + render-inputs: name: Validate immutable render inputs if: ${{ inputs.build }} @@ -146,7 +225,7 @@ jobs: signing-inputs: name: Validate signing inputs if: ${{ inputs.sign }} - needs: prepare + needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release env: @@ -317,6 +396,75 @@ jobs: rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} + desktop-validation: + name: Desktop 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: 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: Install Electron system dependencies + run: pnpm -F @linkcode/desktop exec playwright-core install-deps chromium + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: brand-render-${{ matrix.brandId }} + path: . + - name: Verify real Electron configuration recovery + run: | + set -euo pipefail + printf '%s %s\n' \ + '54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39' \ + 'apps/desktop/e2e/fixtures/pilot-e2e-v1.json' | sha256sum --check --strict + rendered="$RUNNER_TEMP/rendered-desktop-${{ matrix.brandId }}" + mv apps/desktop/generated "$rendered" + status=0 + xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary || status=$? + if [ -e apps/desktop/generated ]; then + echo "::error::the fixture build created release-rendered output" + exit 1 + fi + mv "$rendered" apps/desktop/generated + exit "$status" + - name: Record isolated validation evidence + run: | + set -euo pipefail + root="release-validation/${{ matrix.brandId }}" + mkdir -p "$root" + printf '%s\n' '{"activationVersions":{"emergency":["1","2","3"],"normal":["1","2","3","4"]},"consumer":"desktop","deploymentIdentity":{"kind":"local-static-origin","providerDeploymentId":null},"drills":["emergency-kill-switch","emergency-release","offline-startup","pointer-and-snapshot-tamper","replay-rejection","rollback","roll-forward"],"evidenceVersion":1,"pilotFixtureSha256":"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39","runtime":"electron+xvfb"}' \ + > "$root/validation.desktop.json" + pnpm exec tsx packages/foundation/common/src/node/release-artifact-cli.mts \ + --artifact-root "$root" \ + --artifact validation.desktop.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 }}' \ + --delivery-descriptor release-inputs/brand-build-matrix.json \ + --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ + --release-manifest release-inputs/release-manifest.desktop.json \ + --compliance release-inputs/compliance.desktop.json \ + --out release-provenance.desktop.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: brand-validation-desktop-${{ matrix.brandId }} + path: release-validation/${{ matrix.brandId }} + if-no-files-found: error + retention-days: 7 + mobile-validation: name: Mobile validation ${{ matrix.brandId }} if: ${{ inputs.build && !inputs.sign && !cancelled() && needs.render.result == 'success' }} From 5d09ca98d2d1b12593b8fd097b32ac873e77d10a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 9 Aug 2026 19:39:14 +0000 Subject: [PATCH 15/21] fix(release): harden pilot matrix gates Amp-Thread-ID: https://ampcode.com/threads/T-019fe7ac-7d82-754e-a203-5d0214817d24 --- .../brand-matrices/code-561-pilot.json | 203 ++++++++++++++++++ .github/scripts/brand-matrix.test.mjs | 77 ++++++- .github/workflows/build-desktop.yml | 9 +- .github/workflows/build-mobile.yml | 11 +- .github/workflows/release-brand-matrix.yml | 72 ++++--- 5 files changed, 335 insertions(+), 37 deletions(-) create mode 100644 .github/release/brand-matrices/code-561-pilot.json diff --git a/.github/release/brand-matrices/code-561-pilot.json b/.github/release/brand-matrices/code-561-pilot.json new file mode 100644 index 000000000..ddc0f41ac --- /dev/null +++ b/.github/release/brand-matrices/code-561-pilot.json @@ -0,0 +1,203 @@ +{ + "brandBuildMatrixVersion": 1, + "brands": [ + { + "brandId": "acme", + "channel": "canary", + "compliance": { + "android": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + }, + "desktop": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + }, + "ios": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + } + }, + "distribution": { + "desktop": null, + "mobile": null + }, + "releaseManifests": { + "android": { + "brandId": "acme", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "0675b1b33e81d4898f75233fdf9bda7243348e286ebfd3b06f807d82fff8818f", + "platform": "android", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + }, + "desktop": { + "brandId": "acme", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "936250a3ef922cede3a200b5dc401cc7697ee1db90dc3efd0f873358524f01e3", + "platform": "desktop", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + }, + "ios": { + "brandId": "acme", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "a689a8d95f74d9cb00b5d9850af3ecfd50edb23d2496c71805c9ffe4659d56ae", + "platform": "ios", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + } + } + }, + { + "brandId": "zenith", + "channel": "canary", + "compliance": { + "android": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + }, + "desktop": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + }, + "ios": { + "checklist": { + "configurableFeaturesDisclosed": true, + "dataPracticesReviewed": true, + "noExecutableCode": true, + "permissionsReviewed": true, + "storeMetadataReviewed": true + }, + "disclosedFeatures": [ + "feature.aiAssist", + "feature.newEditor", + "modules.messaging.enabled", + "modules.terminal.enabled", + "modules.workspace.enabled" + ] + } + }, + "distribution": { + "desktop": null, + "mobile": null + }, + "releaseManifests": { + "android": { + "brandId": "zenith", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "a0ef5196645ae3b857343784f7a5ab5d6f5184b15c7cb646d8e86c93ff5384b0", + "platform": "android", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://zenith.example.invalid/telemetry" + }, + "desktop": { + "brandId": "zenith", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "99a93cec0ca5381faa15a5def6727736f220b5d7d111e1fce04afda1d321aef2", + "platform": "desktop", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://zenith.example.invalid/telemetry" + }, + "ios": { + "brandId": "zenith", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "e1b93b64973e0192ed2e1d8ba9a4cca27ae2bb5521ef6011392c2d86b510b95b", + "platform": "ios", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "telemetryEndpoint": "https://zenith.example.invalid/telemetry" + } + } + } + ] +} diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index d54de09c2..6fbc46032 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -19,6 +19,7 @@ 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/; +const ACTIONS_EXPRESSION = String.fromCodePoint(36); function sha(character) { return character.repeat(64); @@ -81,6 +82,31 @@ function matrix(...brands) { } describe('parseBrandBuildMatrix', () => { + it('pins the CODE-561 credential-free pilot to two brands and all platforms', async () => { + const pilot = JSON.parse( + await readFile( + new URL('../release/brand-matrices/code-561-pilot.json', import.meta.url), + 'utf8', + ), + ); + const plan = buildMatrixPlan(pilot); + + expect(plan.targets.include.map(({ brandId, platform }) => `${brandId}/${platform}`)).toEqual([ + 'acme/desktop', + 'acme/ios', + 'acme/android', + 'zenith/desktop', + 'zenith/ios', + 'zenith/android', + ]); + expect( + new Set(pilot.brands.map((entry) => entry.releaseManifests.desktop.publisherGitSha)), + ).toEqual(new Set(['e4a0624abbc8ed1cac4948fa90239176a83cb96e'])); + expect( + pilot.brands.every((entry) => Object.values(entry.distribution).every((x) => x === null)), + ).toBe(true); + }); + it('builds the complete brand by platform plan', () => { const input = matrix(brand('acme'), brand('zenith')); const plan = buildMatrixPlan(input); @@ -239,10 +265,19 @@ describe('release brand matrix workflow', () => { ); expect(validation).toContain('needs: prepare'); + expect(validation).toContain( + `matrix: ${ACTIONS_EXPRESSION}{{ fromJSON(needs.prepare.outputs.targets) }}`, + ); expect(validation).toContain('xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary'); expect(validation).toContain('pnpm -F @linkcode/mobile smoke:export'); - expect(validation).toContain('expo prebuild --clean --no-install --platform android'); - expect(validation).toContain('expo prebuild --clean --no-install --platform ios'); + expect(validation).toContain( + `expo prebuild --clean --no-install --platform '${ACTIONS_EXPRESSION}{{ matrix.platform }}'`, + ); + expect(validation).toContain("matrix.platform == 'desktop'"); + expect(validation).toContain("matrix.platform != 'desktop'"); + expect(validation).toContain( + `credential-free-${ACTIONS_EXPRESSION}{{ matrix.brandId }}-${ACTIONS_EXPRESSION}{{ matrix.platform }}`, + ); expect(validation).toContain('"local-static-origin"'); expect(validation).toContain('providerDeploymentId:null'); expect(validation).not.toContain('environment: release'); @@ -260,24 +295,50 @@ describe('release brand matrix workflow', () => { workflow.indexOf(' render-inputs:'), ); - expect(preflight).toContain('environment: release'); + expect(preflight).not.toContain('environment:'); expect(preflight).toContain('protection_rules'); expect(preflight).toContain('required_reviewers'); expect(preflight).toContain('deployment_branch_policy'); - expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); - expect(preflight).toContain('inputs.sign'); - expect(preflight).not.toContain('inputs.build'); + expect(preflight).toContain( + 'gh api "repos/$GITHUB_REPOSITORY/environments/pilot-nonproduction"', + ); + expect(preflight).toContain('secrets.PILOT_ENVIRONMENT_ADMIN_TOKEN'); + expect(preflight).toContain('inputs.build'); const renderInputs = workflow.slice( workflow.indexOf(' render-inputs:'), workflow.indexOf(' signing-inputs:'), ); - expect(renderInputs).toContain('needs: prepare'); - expect(renderInputs).not.toContain('release-environment-preflight'); + expect(renderInputs).toContain('needs: [prepare, release-environment-preflight]'); + expect(renderInputs).toContain('environment: pilot-nonproduction'); const signingInputs = workflow.slice( workflow.indexOf(' signing-inputs:'), workflow.indexOf(' render:'), ); expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); + expect(signingInputs).toContain('environment: pilot-nonproduction'); + expect(workflow).not.toContain('environment: release'); + expect(workflow.split('release_environment: pilot-nonproduction')).toHaveLength(3); + }); + + it('passes the isolated pilot environment through reusable signing workflows', async () => { + const [desktop, mobile] = await Promise.all([ + readFile(new URL('../workflows/build-desktop.yml', import.meta.url), 'utf8'), + readFile(new URL('../workflows/build-mobile.yml', import.meta.url), 'utf8'), + ]); + + expect(desktop).toContain('release_environment:'); + expect(desktop).toContain( + `environment: ${ACTIONS_EXPRESSION}{{ inputs.release_environment || 'release' }}`, + ); + expect(desktop).toContain( + `environment: ${ACTIONS_EXPRESSION}{{ inputs.sign && (inputs.release_environment || 'release') || '' }}`, + ); + expect(mobile).toContain('release_environment:'); + expect( + mobile.split( + `environment: ${ACTIONS_EXPRESSION}{{ inputs.release_environment || 'release' }}`, + ), + ).toHaveLength(4); }); it('binds credential-free desktop recovery evidence to immutable release inputs', async () => { diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 1ecbe8fb8..cdce70f71 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -40,6 +40,11 @@ on: type: string required: false default: "" + release_environment: + description: GitHub environment that owns signing and publisher credentials + type: string + required: false + default: release # CI builds on PRs — unsigned. # pull_request: # paths: @@ -87,7 +92,7 @@ jobs: name: Render immutable config if: ${{ inputs.sign && inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: ${{ inputs.release_environment || 'release' }} steps: - name: Checkout uses: actions/checkout@v7 @@ -132,7 +137,7 @@ jobs: if: ${{ !cancelled() && (needs.render-config.result == 'success' || needs.render-config.result == 'skipped') }} runs-on: ${{ matrix.os }} # Signing is gated by the `release` environment (secrets + tag policy); '' = no environment. - environment: ${{ inputs.sign && 'release' || '' }} + environment: ${{ inputs.sign && (inputs.release_environment || 'release') || '' }} strategy: fail-fast: false # Runners resolve through repo/org `vars` (ArcBox pins Blacksmith labels there); forks diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index dbf976245..654d93021 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -31,6 +31,11 @@ on: type: boolean required: false default: false + release_environment: + description: GitHub environment that owns build, signing, and store credentials + type: string + required: false + default: release workflow_dispatch: inputs: submit: @@ -79,7 +84,7 @@ jobs: if: ${{ inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} timeout-minutes: 20 - environment: release + environment: ${{ inputs.release_environment || 'release' }} steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -117,7 +122,7 @@ jobs: 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 + environment: ${{ inputs.release_environment || 'release' }} strategy: fail-fast: false matrix: @@ -245,7 +250,7 @@ jobs: needs: build runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} timeout-minutes: 30 - environment: release + environment: ${{ inputs.release_environment || 'release' }} strategy: fail-fast: false matrix: diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 208f4d6ac..997240282 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -128,9 +128,12 @@ jobs: --upload "${{ inputs.upload }}" credential-free-validation: - name: Credential-free runtime validation + name: Credential-free ${{ matrix.brandId }}/${{ matrix.platform }} needs: prepare runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.targets) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -145,6 +148,7 @@ jobs: package-manager-cache: false - run: pnpm install --frozen-lockfile - name: Exercise Electron recovery with Xvfb + if: ${{ matrix.platform == 'desktop' }} run: | set -euo pipefail pnpm -F @linkcode/desktop exec playwright-core install-deps chromium @@ -153,64 +157,82 @@ jobs: 'apps/desktop/e2e/fixtures/pilot-e2e-v1.json' | sha256sum --check --strict xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary - name: Exercise production Hermes and native generation + if: ${{ matrix.platform != 'desktop' }} env: CI: "1" EXPO_NO_TELEMETRY: "1" run: | set -euo pipefail pnpm -F @linkcode/mobile smoke:export - pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform android - test -f apps/mobile/android/app/build.gradle - rm -rf apps/mobile/android - pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform ios - test -f apps/mobile/ios/Podfile - rm -rf apps/mobile/ios + pnpm --dir apps/mobile exec expo prebuild --clean --no-install --platform '${{ matrix.platform }}' + if [ '${{ matrix.platform }}' = android ]; then + test -f apps/mobile/android/app/build.gradle + rm -rf apps/mobile/android + else + test -f apps/mobile/ios/Podfile + rm -rf apps/mobile/ios + fi - name: Record local-only evidence env: + BRAND_ID: ${{ matrix.brandId }} CLIENT_REF: ${{ inputs.ref }} DELIVERY_SHA256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + PLATFORM: ${{ matrix.platform }} run: | mkdir -p credential-free-evidence + if [ "$PLATFORM" = desktop ]; then + runtime=electron+xvfb + else + runtime=production-hermes+prebuild + fi jq -cn \ + --arg brandId "$BRAND_ID" \ --arg clientGitSha "$CLIENT_REF" \ --arg deliveryDescriptorSha256 "$DELIVERY_SHA256" \ - '{clientGitSha:$clientGitSha,deliveryDescriptorSha256:$deliveryDescriptorSha256,deploymentIdentity:{kind:"local-static-origin",providerDeploymentId:null},evidenceVersion:1,pilotFixtureSha256:"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39",runtimes:["electron+xvfb","production-hermes+android-prebuild","production-hermes+ios-prebuild"]}' \ + --arg platform "$PLATFORM" \ + --arg runtime "$runtime" \ + '{brandId:$brandId,clientGitSha:$clientGitSha,deliveryDescriptorSha256:$deliveryDescriptorSha256,deploymentIdentity:{kind:"local-static-origin",providerDeploymentId:null},evidenceVersion:1,pilotFixtureSha256:"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39",platform:$platform,runtime:$runtime}' \ > credential-free-evidence/runtime-validation.json - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: credential-free-runtime-validation + name: credential-free-${{ matrix.brandId }}-${{ matrix.platform }} path: credential-free-evidence if-no-files-found: error retention-days: 7 release-environment-preflight: name: Protected live-pilot preflight - if: ${{ inputs.sign }} + if: ${{ inputs.build }} needs: prepare runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release steps: - name: Require protected nonproduction release environment env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.PILOT_ENVIRONMENT_ADMIN_TOKEN }} run: | set -euo pipefail - environment="$RUNNER_TEMP/release-environment.json" - gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" + if [ -z "$GH_TOKEN" ]; then + echo "::error::PILOT_ENVIRONMENT_ADMIN_TOKEN is required to inspect environment protection" + exit 1 + fi + environment="$RUNNER_TEMP/pilot-environment.json" + gh api "repos/$GITHUB_REPOSITORY/environments/pilot-nonproduction" > "$environment" if ! jq -e ' - ([.protection_rules[]?.type] | index("required_reviewers") != null) and - (.deployment_branch_policy != null) + .name == "pilot-nonproduction" and + ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers | length] | any(. > 0)) and + (.deployment_branch_policy != null) and + (.deployment_branch_policy.protected_branches == true or .deployment_branch_policy.custom_branch_policies == true) ' "$environment" >/dev/null; then - echo "::error::release must require reviewers and a deployment branch policy before a live pilot" + echo "::error::pilot-nonproduction must require reviewers and a deployment branch policy before a live pilot" exit 1 fi render-inputs: name: Validate immutable render inputs if: ${{ inputs.build }} - needs: prepare + needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction env: CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} CONFIG_PUBLISHER_TOKEN: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} @@ -227,7 +249,7 @@ jobs: if: ${{ inputs.sign }} needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction env: APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} @@ -264,7 +286,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -393,6 +415,7 @@ jobs: sign: ${{ inputs.sign }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + release_environment: pilot-nonproduction rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} @@ -531,6 +554,7 @@ jobs: ref: ${{ inputs.ref }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + release_environment: pilot-nonproduction rendered_artifact: brand-render-${{ matrix.brandId }} submit: false @@ -542,7 +566,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} @@ -622,7 +646,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -698,7 +722,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: pilot-nonproduction 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)] }} From 04f4949cebd2d399b8cfa6fcdb439d78f82f95ea Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Sun, 9 Aug 2026 21:04:30 +0000 Subject: [PATCH 16/21] fix(release): use protected release environment Amp-Thread-ID: https://ampcode.com/threads/T-019fe7ac-7d82-754e-a203-5d0214817d24 --- .github/scripts/brand-matrix.test.mjs | 18 ++++++------ .github/workflows/release-brand-matrix.yml | 32 +++++++++++----------- docs/ENVIRONMENT.md | 1 + 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index 6fbc46032..e5a554e2e 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -285,7 +285,7 @@ describe('release brand matrix workflow', () => { expect(validation).not.toContain('release-environment-preflight'); }); - it('fails closed unless the live-pilot environment is protected', async () => { + it('fails closed unless the release environment is protected', async () => { const workflow = await readFile( new URL('../workflows/release-brand-matrix.yml', import.meta.url), 'utf8', @@ -299,28 +299,26 @@ describe('release brand matrix workflow', () => { expect(preflight).toContain('protection_rules'); expect(preflight).toContain('required_reviewers'); expect(preflight).toContain('deployment_branch_policy'); - expect(preflight).toContain( - 'gh api "repos/$GITHUB_REPOSITORY/environments/pilot-nonproduction"', - ); - expect(preflight).toContain('secrets.PILOT_ENVIRONMENT_ADMIN_TOKEN'); + expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); + expect(preflight).toContain('secrets.RELEASE_ENVIRONMENT_ADMIN_TOKEN'); expect(preflight).toContain('inputs.build'); const renderInputs = workflow.slice( workflow.indexOf(' render-inputs:'), workflow.indexOf(' signing-inputs:'), ); expect(renderInputs).toContain('needs: [prepare, release-environment-preflight]'); - expect(renderInputs).toContain('environment: pilot-nonproduction'); + expect(renderInputs).toContain('environment: release'); const signingInputs = workflow.slice( workflow.indexOf(' signing-inputs:'), workflow.indexOf(' render:'), ); expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); - expect(signingInputs).toContain('environment: pilot-nonproduction'); - expect(workflow).not.toContain('environment: release'); - expect(workflow.split('release_environment: pilot-nonproduction')).toHaveLength(3); + expect(signingInputs).toContain('environment: release'); + expect(workflow.split(' environment: release')).toHaveLength(7); + expect(workflow.split('release_environment: release')).toHaveLength(3); }); - it('passes the isolated pilot environment through reusable signing workflows', async () => { + it('passes the release environment through reusable signing workflows', async () => { const [desktop, mobile] = await Promise.all([ readFile(new URL('../workflows/build-desktop.yml', import.meta.url), 'utf8'), readFile(new URL('../workflows/build-mobile.yml', import.meta.url), 'utf8'), diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 997240282..82c9f56a1 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -201,29 +201,29 @@ jobs: retention-days: 7 release-environment-preflight: - name: Protected live-pilot preflight + name: Protected release environment preflight if: ${{ inputs.build }} needs: prepare runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} steps: - - name: Require protected nonproduction release environment + - name: Require protected release environment env: - GH_TOKEN: ${{ secrets.PILOT_ENVIRONMENT_ADMIN_TOKEN }} + GH_TOKEN: ${{ secrets.RELEASE_ENVIRONMENT_ADMIN_TOKEN }} run: | set -euo pipefail if [ -z "$GH_TOKEN" ]; then - echo "::error::PILOT_ENVIRONMENT_ADMIN_TOKEN is required to inspect environment protection" + echo "::error::RELEASE_ENVIRONMENT_ADMIN_TOKEN is required to inspect environment protection" exit 1 fi - environment="$RUNNER_TEMP/pilot-environment.json" - gh api "repos/$GITHUB_REPOSITORY/environments/pilot-nonproduction" > "$environment" + environment="$RUNNER_TEMP/release-environment.json" + gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" if ! jq -e ' - .name == "pilot-nonproduction" and + .name == "release" and ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers | length] | any(. > 0)) and (.deployment_branch_policy != null) and (.deployment_branch_policy.protected_branches == true or .deployment_branch_policy.custom_branch_policies == true) ' "$environment" >/dev/null; then - echo "::error::pilot-nonproduction must require reviewers and a deployment branch policy before a live pilot" + echo "::error::release must require reviewers and a deployment branch policy before release work" exit 1 fi @@ -232,7 +232,7 @@ jobs: if: ${{ inputs.build }} needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release env: CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} CONFIG_PUBLISHER_TOKEN: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} @@ -249,7 +249,7 @@ jobs: if: ${{ inputs.sign }} needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release env: APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} @@ -286,7 +286,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -415,7 +415,7 @@ jobs: sign: ${{ inputs.sign }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} - release_environment: pilot-nonproduction + release_environment: release rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} @@ -554,7 +554,7 @@ jobs: ref: ${{ inputs.ref }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} - release_environment: pilot-nonproduction + release_environment: release rendered_artifact: brand-render-${{ matrix.brandId }} submit: false @@ -566,7 +566,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} R2_ACCESS_KEY_ID: ${{ secrets[format('{0}_R2_ACCESS_KEY_ID', matrix.distribution.desktop.credentialSecretPrefix)] }} @@ -646,7 +646,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + environment: release steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -722,7 +722,7 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: pilot-nonproduction + 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)] }} diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 18b34708b..a8e8c287e 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -129,6 +129,7 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `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. | +| `RELEASE_ENVIRONMENT_ADMIN_TOKEN` | `release-brand-matrix.yml` | Repository/org-scoped token authorized to inspect the `release` environment configuration. The preflight runs before entering that environment and fails unless it has required reviewers and a non-null deployment branch policy, so this token cannot be stored only inside `release`. | | `_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. | From 03be67f0409f8d7a9f3cff20b3f569043c62189d Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 10 Aug 2026 12:07:53 +0000 Subject: [PATCH 17/21] feat(release): render independent config source Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .../actions/render-release-config/action.yml | 86 ++++++++++++++----- .../brand-matrices/code-561-pilot.json | 24 +++--- .github/scripts/brand-matrix.test.mjs | 66 +++++++++++++- .github/scripts/release-inputs.cjs | 2 - .github/scripts/release-inputs.test.mjs | 4 +- .github/workflows/build-desktop.yml | 43 ++++++++-- .github/workflows/build-mobile.yml | 35 +++++++- .github/workflows/release-brand-matrix.yml | 53 +++++++++--- docs/ENVIRONMENT.md | 6 +- docs/RELEASE.md | 43 +++++++--- 10 files changed, 288 insertions(+), 74 deletions(-) diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index 171ef99e9..672a15fc8 100644 --- a/.github/actions/render-release-config/action.yml +++ b/.github/actions/render-release-config/action.yml @@ -9,14 +9,16 @@ inputs: app: description: Which app to render for (desktop or mobile) required: true - publisher-repo: - description: owner/name of the config publisher repository (vars.CONFIG_PUBLISHER_REPO) - required: false - default: "" publisher-token: - description: Token that can read the config publisher repository (secrets.CONFIG_PUBLISHER_TOKEN) + description: Short-lived Contents read token restricted to arcboxlabs/linkcodehq + required: true + source-token: + description: Short-lived Contents read token restricted to arcboxlabs/linkcode-config + required: true + source-root: + description: Reviewed source root (repository root for production or examples/acme-zenith) required: false - default: "" + default: "." revision: description: Config revision metadata JSON content (vars.CONFIG_RELEASE_REVISION) required: false @@ -49,8 +51,9 @@ runs: shell: bash env: APP: ${{ inputs.app }} - PUBLISHER_REPO: ${{ inputs.publisher-repo }} PUBLISHER_TOKEN: ${{ inputs.publisher-token }} + SOURCE_TOKEN: ${{ inputs.source-token }} + SOURCE_ROOT: ${{ inputs.source-root }} REVISION_JSON: ${{ inputs.revision }} KEYRINGS_JSON: ${{ inputs.keyrings }} MANIFEST_DESKTOP: ${{ inputs.release-manifest }} @@ -60,9 +63,12 @@ runs: run: | set -euo pipefail + if [ -z "$PUBLISHER_TOKEN" ] || [ -z "$SOURCE_TOKEN" ]; then + echo "::error::render-release-config requires separate short-lived Contents read tokens for arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + missing=() - [ -n "$PUBLISHER_REPO" ] || missing+=(CONFIG_PUBLISHER_REPO) - [ -n "$PUBLISHER_TOKEN" ] || missing+=(CONFIG_PUBLISHER_TOKEN) [ -n "$REVISION_JSON" ] || missing+=(CONFIG_RELEASE_REVISION) [ -n "$KEYRINGS_JSON" ] || missing+=(CONFIG_RELEASE_KEYRINGS) case "$APP" in @@ -120,27 +126,67 @@ runs: brand="$(pin "$primary" .brandId)" channel="$(pin "$primary" .channel)" telemetry="$(pin "$primary" .telemetryEndpoint)" + if [[ ! "$publisher_sha" =~ ^[0-9a-f]{40}$ ]] || [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::publisherGitSha and sourceGitSha must be exact lowercase 40-hex commits" + exit 1 + fi - # Fetch exactly the two pinned commits — never a branch head — and keep the token out of - # persisted git config by passing it per command. - auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$PUBLISHER_TOKEN" | base64 -w0)" + # Repository identities and source root are code-owned; release data controls only SHAs. + publisher_repo=arcboxlabs/linkcodehq + source_repo=arcboxlabs/linkcode-config + case "$SOURCE_ROOT" in + .|examples/acme-zenith) ;; + *) echo "::error::source-root must be the production repository root or the reviewed nonproduction example root"; exit 1 ;; + esac publisher="$work/publisher" - git init -q "$publisher" - git -C "$publisher" remote add origin "https://github.com/${PUBLISHER_REPO}.git" - if ! git -C "$publisher" -c "http.https://github.com/.extraheader=$auth" \ - fetch -q --depth 1 origin "$publisher_sha" "$source_sha"; then - echo "::error::Could not fetch pinned commits ${publisher_sha} / ${source_sha} from the config publisher repository. Release builds require read access to the private publisher repository and both pinned commits to exist." + source="$work/source" + + checkout_pinned() { + local dir="$1" repo="$2" sha="$3" token="$4" label="$5" + local auth + auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$token" | base64 -w0)" + git init -q "$dir" + git -C "$dir" remote add origin "https://github.com/${repo}.git" + if ! git -C "$dir" -c "http.https://github.com/.extraheader=$auth" \ + -c http.followRedirects=false \ + fetch -q --depth 1 origin "$sha"; then + echo "::error::Could not fetch $label commit $sha from $repo. Confirm the org App is installed on that private repository with Contents: read and the commit exists." + exit 1 + fi + git -C "$dir" checkout -q --detach FETCH_HEAD + if [ "$(git -C "$dir" rev-parse HEAD)" != "$sha" ] || \ + [ "$(git -C "$dir" remote get-url origin)" != "https://github.com/${repo}.git" ]; then + echo "::error::$label checkout identity did not match fixed repository $repo at $sha" + exit 1 + fi + } + + checkout_pinned "$publisher" "$publisher_repo" "$publisher_sha" "$PUBLISHER_TOKEN" publisher + checkout_pinned "$source" "$source_repo" "$source_sha" "$SOURCE_TOKEN" "config source" + + structural="$source/$SOURCE_ROOT" + if [ ! -f "$structural/brands.manifest.yaml" ] || \ + [ ! -f "$structural/schema/config.schema.json" ]; then + echo "::error::Pinned config source must contain source root $SOURCE_ROOT with its manifest and schema mirror; production root is intentionally unavailable until reviewed production data exists" + exit 1 + fi + if find "$structural" -type l -print -quit | grep -q .; then + echo "::error::Pinned config source root must not contain symbolic links" + exit 1 + fi + if ! cmp -s \ + "$publisher/packages/config-structural/schema/config.schema.json" \ + "$structural/schema/config.schema.json"; then + echo "::error::Config source schema mirror differs byte-for-byte from the canonical schema at publisher commit $publisher_sha" exit 1 fi - git -C "$publisher" checkout -q "$publisher_sha" - git -C "$publisher" worktree add -q --detach "$work/source" "$source_sha" pnpm --dir "$publisher" install --frozen-lockfile common_args=( --publisher "$publisher" --publisher-git-sha "$publisher_sha" - --structural "$work/source/packages/config-structural" + --structural "$structural" --source-git-sha "$source_sha" --revision "$work/revision.json" --keyrings "$work/keyrings.json" diff --git a/.github/release/brand-matrices/code-561-pilot.json b/.github/release/brand-matrices/code-561-pilot.json index ddc0f41ac..bd1585960 100644 --- a/.github/release/brand-matrices/code-561-pilot.json +++ b/.github/release/brand-matrices/code-561-pilot.json @@ -66,10 +66,10 @@ "expectedSnapshotSha256": "0675b1b33e81d4898f75233fdf9bda7243348e286ebfd3b06f807d82fff8818f", "platform": "android", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://acme.example.invalid/telemetry" }, "desktop": { @@ -79,10 +79,10 @@ "expectedSnapshotSha256": "936250a3ef922cede3a200b5dc401cc7697ee1db90dc3efd0f873358524f01e3", "platform": "desktop", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://acme.example.invalid/telemetry" }, "ios": { @@ -92,10 +92,10 @@ "expectedSnapshotSha256": "a689a8d95f74d9cb00b5d9850af3ecfd50edb23d2496c71805c9ffe4659d56ae", "platform": "ios", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://acme.example.invalid/telemetry" } } @@ -165,10 +165,10 @@ "expectedSnapshotSha256": "a0ef5196645ae3b857343784f7a5ab5d6f5184b15c7cb646d8e86c93ff5384b0", "platform": "android", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://zenith.example.invalid/telemetry" }, "desktop": { @@ -178,10 +178,10 @@ "expectedSnapshotSha256": "99a93cec0ca5381faa15a5def6727736f220b5d7d111e1fce04afda1d321aef2", "platform": "desktop", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://zenith.example.invalid/telemetry" }, "ios": { @@ -191,10 +191,10 @@ "expectedSnapshotSha256": "e1b93b64973e0192ed2e1d8ba9a4cca27ae2bb5521ef6011392c2d86b510b95b", "platform": "ios", "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", - "publisherGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", "releaseManifestFormatVersion": 1, "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", - "sourceGitSha": "e4a0624abbc8ed1cac4948fa90239176a83cb96e", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://zenith.example.invalid/telemetry" } } diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index e5a554e2e..63c915848 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -101,7 +101,17 @@ describe('parseBrandBuildMatrix', () => { ]); expect( new Set(pilot.brands.map((entry) => entry.releaseManifests.desktop.publisherGitSha)), - ).toEqual(new Set(['e4a0624abbc8ed1cac4948fa90239176a83cb96e'])); + ).toEqual(new Set(['986d9f21403df53bc932f511eb1b5f0bb634d48d'])); + expect( + new Set(pilot.brands.map((entry) => entry.releaseManifests.desktop.sourceGitSha)), + ).toEqual(new Set(['a1ed4d666721c3aed0d563aaea42fce8b5f945b5'])); + expect( + pilot.brands.every( + (entry) => + entry.releaseManifests.desktop.publisherGitSha !== + entry.releaseManifests.desktop.sourceGitSha, + ), + ).toBe(true); expect( pilot.brands.every((entry) => Object.values(entry.distribution).every((x) => x === null)), ).toBe(true); @@ -300,7 +310,9 @@ describe('release brand matrix workflow', () => { expect(preflight).toContain('required_reviewers'); expect(preflight).toContain('deployment_branch_policy'); expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); - expect(preflight).toContain('secrets.RELEASE_ENVIRONMENT_ADMIN_TOKEN'); + expect(preflight).toContain(`GH_TOKEN: ${ACTIONS_EXPRESSION}{{ github.token }}`); + expect(preflight).not.toContain('RELEASE_ENVIRONMENT_ADMIN_TOKEN'); + expect(workflow).toContain('actions: read'); expect(preflight).toContain('inputs.build'); const renderInputs = workflow.slice( workflow.indexOf(' render-inputs:'), @@ -339,6 +351,56 @@ describe('release brand matrix workflow', () => { ).toHaveLength(4); }); + it('mints scoped read tokens before any selected client checkout', async () => { + const [action, desktop, mobile, workflow] = await Promise.all([ + readFile(new URL('../actions/render-release-config/action.yml', import.meta.url), 'utf8'), + readFile(new URL('../workflows/build-desktop.yml', import.meta.url), 'utf8'), + readFile(new URL('../workflows/build-mobile.yml', import.meta.url), 'utf8'), + readFile(new URL('../workflows/release-brand-matrix.yml', import.meta.url), 'utf8'), + ]); + const appTokenAction = + 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1'; + + expect(action).toContain('publisher-token:'); + expect(action).toContain('source-token:'); + expect(action).not.toContain(appTokenAction); + expect(action).not.toContain('github-app-private-key'); + expect(action).not.toContain('BOT_APP_PRIVATE_KEY'); + expect(action).toContain('publisher_repo=arcboxlabs/linkcodehq'); + expect(action).toContain('source_repo=arcboxlabs/linkcode-config'); + expect(action).toContain('default: "."'); + expect(action).toContain('.|examples/acme-zenith)'); + expect(action).toContain('cmp -s'); + expect(action).toContain('http.followRedirects=false'); + expect(action).toContain('must be exact lowercase 40-hex commits'); + expect(action).toContain('must not contain symbolic links'); + expect(action).not.toContain('CONFIG_PUBLISHER_REPO'); + expect(action).not.toContain('CONFIG_PUBLISHER_TOKEN'); + + const renderJobs = [ + desktop.slice(desktop.indexOf(' render-config:'), desktop.indexOf(' build:')), + mobile.slice(mobile.indexOf(' render-config:'), mobile.indexOf(' build:')), + workflow.slice(workflow.indexOf(' render:'), workflow.indexOf(' desktop:')), + ]; + for (const renderJob of renderJobs) { + expect(renderJob.split(appTokenAction)).toHaveLength(3); + expect(renderJob.split('owner: arcboxlabs')).toHaveLength(3); + expect(renderJob).toContain('repositories: linkcodehq'); + expect(renderJob).toContain('repositories: linkcode-config'); + expect(renderJob.split('permission-contents: read')).toHaveLength(3); + expect(renderJob).toContain( + `publisher-token: ${ACTIONS_EXPRESSION}{{ steps.publisher-token.outputs.token }}`, + ); + expect(renderJob).toContain( + `source-token: ${ACTIONS_EXPRESSION}{{ steps.source-token.outputs.token }}`, + ); + expect(renderJob.indexOf(appTokenAction)).toBeLessThan( + renderJob.indexOf('actions/checkout@'), + ); + } + expect(workflow.split('source-root: examples/acme-zenith')).toHaveLength(3); + }); + it('binds credential-free desktop recovery evidence to immutable release inputs', async () => { const workflow = await readFile( new URL('../workflows/release-brand-matrix.yml', import.meta.url), diff --git a/.github/scripts/release-inputs.cjs b/.github/scripts/release-inputs.cjs index 2a780d3df..c138b6437 100644 --- a/.github/scripts/release-inputs.cjs +++ b/.github/scripts/release-inputs.cjs @@ -6,8 +6,6 @@ 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'], ], diff --git a/.github/scripts/release-inputs.test.mjs b/.github/scripts/release-inputs.test.mjs index cf476a8fb..1d8953c09 100644 --- a/.github/scripts/release-inputs.test.mjs +++ b/.github/scripts/release-inputs.test.mjs @@ -2,7 +2,7 @@ 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_RENDER_MISSING = /var CONFIG_RELEASE_KEYRINGS.*var CONFIG_RELEASE_REVISION/; 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/; @@ -10,7 +10,7 @@ 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', () => { + it('reports absent render vars by exact GitHub name', () => { expect(() => validateReleaseInputs({ env: {}, phase: 'render', platform: 'desktop' })).toThrow( RE_RENDER_MISSING, ); diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index cdce70f71..4e3d7bc90 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -94,17 +94,48 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: ${{ inputs.release_environment || 'release' }} steps: + - name: Require organization App credentials + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + run: | + if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + + - name: Mint publisher read token + id: publisher-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcodehq + permission-contents: read + + - name: Mint config source read token + id: source-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcode-config + permission-contents: read + - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref || github.ref }} + persist-credentials: false - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: run_install: false cache: true - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version-file: .nvmrc package-manager-cache: false @@ -116,14 +147,14 @@ jobs: uses: ./.github/actions/render-release-config with: app: desktop - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + publisher-token: ${{ steps.publisher-token.outputs.token }} + source-token: ${{ steps.source-token.outputs.token }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest: ${{ vars.CONFIG_RELEASE_MANIFEST_DESKTOP }} - name: Upload rendered bundle - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-config-bundle path: apps/desktop/generated/config-build-bundle.json diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 654d93021..9a5bc0982 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -86,10 +86,41 @@ jobs: timeout-minutes: 20 environment: ${{ inputs.release_environment || 'release' }} steps: + - name: Require organization App credentials + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + run: | + if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + + - name: Mint publisher read token + id: publisher-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcodehq + permission-contents: read + + - name: Mint config source read token + id: source-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcode-config + permission-contents: read + - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref || github.ref }} + persist-credentials: false - name: Setup EAS uses: ./.github/actions/setup-eas @@ -101,8 +132,8 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + publisher-token: ${{ steps.publisher-token.outputs.token }} + source-token: ${{ steps.source-token.outputs.token }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest-ios: ${{ vars.CONFIG_RELEASE_MANIFEST_IOS }} diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 82c9f56a1..2cfe9cc57 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -38,8 +38,8 @@ concurrency: cancel-in-progress: false permissions: + actions: read contents: read - deployments: read jobs: prepare: @@ -208,13 +208,9 @@ jobs: steps: - name: Require protected release environment env: - GH_TOKEN: ${{ secrets.RELEASE_ENVIRONMENT_ADMIN_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [ -z "$GH_TOKEN" ]; then - echo "::error::RELEASE_ENVIRONMENT_ADMIN_TOKEN is required to inspect environment protection" - exit 1 - fi environment="$RUNNER_TEMP/release-environment.json" gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" if ! jq -e ' @@ -234,8 +230,6 @@ jobs: 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: @@ -288,9 +282,40 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release steps: + - name: Require organization App credentials + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + run: | + if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + + - name: Mint publisher read token + id: publisher-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcodehq + permission-contents: read + + - name: Mint config source read token + id: source-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcode-config + permission-contents: read + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: ref: ${{ inputs.ref }} + persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: @@ -309,8 +334,9 @@ jobs: with: app: desktop brand-artifacts: true - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + publisher-token: ${{ steps.publisher-token.outputs.token }} + source-token: ${{ steps.source-token.outputs.token }} + source-root: examples/acme-zenith revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest: ${{ toJSON(matrix.releaseManifests.desktop) }} @@ -319,8 +345,9 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + publisher-token: ${{ steps.publisher-token.outputs.token }} + source-token: ${{ steps.source-token.outputs.token }} + source-root: examples/acme-zenith revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest-ios: ${{ toJSON(matrix.releaseManifests.ios) }} @@ -341,7 +368,7 @@ jobs: set -euo pipefail mkdir release-inputs git cat-file blob "$CLIENT_REF:$MATRIX_FILE" > release-inputs/brand-build-matrix.json - cp "$RUNNER_TEMP/config-render-desktop/source/packages/config-structural/brands.manifest.yaml" release-inputs/ + cp "$RUNNER_TEMP/config-render-desktop/source/examples/acme-zenith/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 diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index a8e8c287e..fa01d067a 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -110,7 +110,7 @@ client configuration or new build. | `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` | `apps/desktop/scripts/stage-sidecar.mts` | `aarch64-linux-gnu-gcc` for the linux-arm64 sidecar cross-build. | | `NODE_OPTIONS` | `.github/workflows/ci.yml` | `--max-old-space-size=4096` for every CI job. | | `POSTHOG_HOST` | desktop/mobile build workflows | Organization Actions variable mapped to the platform-specific PostHog host for production bundles. | -| `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. | +| `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars containing exact revision/public-keyring JSON bytes; release manifests digest-bind the JSON inputs. Publisher and source repository identities are fixed in workflow code. | ## Release-only secrets @@ -128,10 +128,8 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `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. | -| `RELEASE_ENVIRONMENT_ADMIN_TOKEN` | `release-brand-matrix.yml` | Repository/org-scoped token authorized to inspect the `release` environment configuration. The preflight runs before entering that environment and fails unless it has required reviewers and a non-null deployment branch policy, so this token cannot be stored only inside `release`. | | `_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. | +| `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | release and config-render workflows | Organization 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; install it on private `arcboxlabs/linkcodehq` and `arcboxlabs/linkcode-config` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only package-manager bumps remain an optional self-skip. | Mobile certificates, provisioning profiles, the Android keystore, the App Store Connect API key, and the Google Play service-account key are EAS-managed credentials, not GitHub variables. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 68d241d2f..c7362dbcf 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -89,12 +89,21 @@ Desktop signing and R2 secrets live in the repo's GitHub **`release` Environment ## Immutable config bundle (build-time render) -Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out the publisher and structural source at the **exact commits pinned by the release-render manifest**, renders through `pnpm -F @linkcode/ config:render`, and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, a global install, or stale generated output. +Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out publisher code from fixed `arcboxlabs/linkcodehq` at `publisherGitSha` and structural data from fixed `arcboxlabs/linkcode-config` at the independent `sourceGitSha`. It renders through `pnpm -F @linkcode/ config:render` and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, a configurable repository, a global install, or stale generated output. + +Each checkout uses its own short-lived installation token minted from the organization secrets +`BOT_APP_ID` and `BOT_APP_PRIVATE_KEY`. Trusted workflow steps mint these tokens before checking out +the selected client ref; client-controlled actions receive only repository-scoped read tokens, +never the App private key. Each token requests only Contents read and is explicitly limited to +`linkcodehq` or `linkcode-config`. The App must be installed on both private repositories. Missing +secrets or installation access fail before rendering; no long-lived config-read token is used. + +Production rendering reads the root of `linkcode-config` and fails closed while production data is +absent. Workflow code may select only that root or the reviewed `examples/acme-zenith` root used by +the nonproduction pilot; configuration data cannot supply a repository or path. Inputs live in the GitHub **`release` environment** and a missing value fails the build with an actionable error: -- `CONFIG_PUBLISHER_REPO` (var) — `owner/name` of the private config publisher repository. -- `CONFIG_PUBLISHER_TOKEN` (secret) — read token for that repository. - `CONFIG_RELEASE_REVISION` / `CONFIG_RELEASE_KEYRINGS` (vars) — exact revision-metadata and public-keyrings JSON bytes; the manifest pins their SHA-256s, so drifted content fails closed. Public keys only — private keys never enter this repo or its CI. - `CONFIG_RELEASE_MANIFEST_DESKTOP` / `CONFIG_RELEASE_MANIFEST_IOS` / `CONFIG_RELEASE_MANIFEST_ANDROID` (vars) — release-render manifest v1 JSON per target (produced by the publisher's release flow), pinning `publisherGitSha`, `sourceGitSha`, brand/platform/channel, telemetry endpoint, input digests, and the expected published snapshot digest. @@ -114,6 +123,13 @@ the selected matrix and needs no credential. `build: true, sign: false` renders 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 committed `code-561-pilot.json` is deterministic nonproduction evidence only. It pins +publisher `986d9f21403df53bc932f511eb1b5f0bb634d48d`, source +`a1ed4d666721c3aed0d563aaea42fce8b5f945b5`, and the source root +`examples/acme-zenith`. The render action byte-compares that root's generated schema mirror with +the canonical schema in the pinned publisher checkout before parsing. Acme and Zenith, their +`.invalid` endpoints, and this example root are not production brand data. + The JSON root contains `brandBuildMatrixVersion: 1` and a non-empty `brands` array. Every brand has exactly `brandId`, `channel`, `releaseManifests`, `compliance`, and `distribution`: @@ -151,14 +167,17 @@ 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. The scripts -report every missing name and never default a signing or upload input: - -- Vars: `CONFIG_PUBLISHER_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS`, and - `POSTHOG_HOST`. Revision/keyring values are exact JSON bytes already digest-pinned by each release - manifest. -- Config source: secret `CONFIG_PUBLISHER_TOKEN`, a fine-grained token with **Contents: read** only - on `CONFIG_PUBLISHER_REPO`; no write or organization scope. +Signing secrets and render vars below are read from the protected `release` environment; the bot +credentials are organization secrets. Trusted workflow steps report missing bot credentials before +checking out selected client code, and the input scripts report missing render, signing, or upload +values without receiving those bot credentials: + +- Vars: `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. + Revision/keyring values are exact JSON bytes already digest-pinned by each release manifest. +- Config checkouts: organization secrets `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY` mint separate, + short-lived installation tokens with **Contents: read** only on `arcboxlabs/linkcodehq` and + `arcboxlabs/linkcode-config`. The workflow fixes both repository identities and requests no write + or organization permission. - 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. @@ -184,6 +203,8 @@ report every missing name and never default a signing or upload input: Do not store private signing material, access tokens, or service-account JSON in the committed matrix, repository files, artifacts, or Actions vars. Protect the `release` environment with required reviewers and exact deployment ref rules before enabling `sign` or `upload`. +The environment preflight reads protection metadata with the built-in `GITHUB_TOKEN` and explicit +`actions: read`; this metadata-only token cannot approve or bypass an environment review. ## Packaging inputs (staging & version pins) From b40c3f960e5d0fafb11dfa36f4856abf76534acb Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 10 Aug 2026 12:08:01 +0000 Subject: [PATCH 18/21] ci(config): validate cross-repository contract Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .github/workflows/ci.yml | 84 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c856fdd88..348e6dea1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -280,6 +280,86 @@ jobs: - name: Test run: cargo test --locked + config-integration: + name: Cross-repository config contract + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + timeout-minutes: 10 + steps: + - name: Require organization App credentials + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + run: | + if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so CI can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + exit 1 + fi + + - name: Mint publisher read token + id: publisher-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcodehq + permission-contents: read + + - name: Mint config source read token + id: source-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + owner: arcboxlabs + repositories: linkcode-config + permission-contents: read + + - name: Check out pinned publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: arcboxlabs/linkcodehq + ref: 986d9f21403df53bc932f511eb1b5f0bb634d48d + token: ${{ steps.publisher-token.outputs.token }} + path: .config-validation/linkcodehq + persist-credentials: false + + - name: Check out pinned config source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: arcboxlabs/linkcode-config + ref: a1ed4d666721c3aed0d563aaea42fce8b5f945b5 + token: ${{ steps.source-token.outputs.token }} + path: .config-validation/linkcode-config + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + version: 11.9.0 + run_install: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + package-manager-cache: false + + - name: Validate pinned source with the pinned parser + run: | + set -euo pipefail + hq="$GITHUB_WORKSPACE/.config-validation/linkcodehq" + config="$GITHUB_WORKSPACE/.config-validation/linkcode-config" + test "$(git -C "$hq" rev-parse HEAD)" = 986d9f21403df53bc932f511eb1b5f0bb634d48d + test "$(git -C "$config" rev-parse HEAD)" = a1ed4d666721c3aed0d563aaea42fce8b5f945b5 + test "$(node --version | cut -d. -f1)" = v24 + test "$(pnpm --version)" = 11.9.0 + pnpm --dir "$hq" --filter @linkcodehq/config-structural... \ + install --frozen-lockfile --ignore-scripts + pnpm --dir "$hq" --filter @linkcodehq/config-structural exec tsx \ + "$config/scripts/validate.mts" \ + --hq-root "$hq" \ + --source-root examples/acme-zenith + all-green: name: All Green runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} @@ -290,6 +370,7 @@ jobs: - webview - mobile - rust + - config-integration if: always() steps: @@ -298,3 +379,6 @@ jobs: if [ '${{ needs.typescript.result }}' != 'success' ] || [ '${{ needs.desktop.result }}' != 'success' ] || [ '${{ needs.webview.result }}' != 'success' ] || [ '${{ needs.mobile.result }}' != 'success' ] || [ '${{ needs.rust.result }}' != 'success' ]; then exit 1 fi + if [ '${{ needs.config-integration.result }}' != 'success' ] && [ '${{ needs.config-integration.result }}' != 'skipped' ]; then + exit 1 + fi From 31ee8251e8212624e2ccbdfc6030e578ab5dbc7c Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 01:05:25 +0000 Subject: [PATCH 19/21] fix(release): select both config repositories Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .../actions/render-release-config/action.yml | 81 +++++++++++++++++-- .github/scripts/brand-matrix.test.mjs | 60 ++++++++++++-- .github/scripts/release-inputs.cjs | 19 +++++ .github/scripts/release-inputs.test.mjs | 79 +++++++++++++++++- .github/workflows/build-desktop.yml | 44 ++++++++-- .github/workflows/build-mobile.yml | 38 ++++++++- .github/workflows/release-brand-matrix.yml | 42 +++++++++- docs/ENVIRONMENT.md | 4 +- docs/RELEASE.md | 33 +++++--- 9 files changed, 362 insertions(+), 38 deletions(-) diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index 672a15fc8..2edab2dc1 100644 --- a/.github/actions/render-release-config/action.yml +++ b/.github/actions/render-release-config/action.yml @@ -9,11 +9,17 @@ inputs: app: description: Which app to render for (desktop or mobile) required: true + publisher-repository: + description: Validated owner/name from vars.CONFIG_PUBLISHER_REPO + required: true publisher-token: - description: Short-lived Contents read token restricted to arcboxlabs/linkcodehq + description: Short-lived Contents read token restricted to publisher-repository + required: true + source-repository: + description: Validated owner/name from vars.CONFIG_SOURCE_REPO required: true source-token: - description: Short-lived Contents read token restricted to arcboxlabs/linkcode-config + description: Short-lived Contents read token restricted to source-repository required: true source-root: description: Reviewed source root (repository root for production or examples/acme-zenith) @@ -51,7 +57,9 @@ runs: shell: bash env: APP: ${{ inputs.app }} + PUBLISHER_REPO: ${{ inputs.publisher-repository }} PUBLISHER_TOKEN: ${{ inputs.publisher-token }} + SOURCE_REPO: ${{ inputs.source-repository }} SOURCE_TOKEN: ${{ inputs.source-token }} SOURCE_ROOT: ${{ inputs.source-root }} REVISION_JSON: ${{ inputs.revision }} @@ -63,8 +71,20 @@ runs: run: | set -euo pipefail + if [[ ! "$PUBLISHER_REPO" =~ ^arcboxlabs/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]]; then + echo "::error::CONFIG_PUBLISHER_REPO must use canonical arcboxlabs/repository syntax" + exit 1 + fi + if [[ ! "$SOURCE_REPO" =~ ^arcboxlabs/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]]; then + echo "::error::CONFIG_SOURCE_REPO must use canonical arcboxlabs/repository syntax" + exit 1 + fi + if [ "$PUBLISHER_REPO" = "$SOURCE_REPO" ]; then + echo "::error::CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories" + exit 1 + fi if [ -z "$PUBLISHER_TOKEN" ] || [ -z "$SOURCE_TOKEN" ]; then - echo "::error::render-release-config requires separate short-lived Contents read tokens for arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + echo "::error::render-release-config requires separate short-lived Contents read tokens for $PUBLISHER_REPO and $SOURCE_REPO" exit 1 fi @@ -131,9 +151,10 @@ runs: exit 1 fi - # Repository identities and source root are code-owned; release data controls only SHAs. - publisher_repo=arcboxlabs/linkcodehq - source_repo=arcboxlabs/linkcode-config + # Repository identities are release-environment owned; the source root is workflow-owned. + # Manifests control exact commits and bind the rendered release inputs. + publisher_repo="$PUBLISHER_REPO" + source_repo="$SOURCE_REPO" case "$SOURCE_ROOT" in .|examples/acme-zenith) ;; *) echo "::error::source-root must be the production repository root or the reviewed nonproduction example root"; exit 1 ;; @@ -143,10 +164,42 @@ runs: checkout_pinned() { local dir="$1" repo="$2" sha="$3" token="$4" label="$5" - local auth + local auth comparison extra reviewed reviewed_name reviewed_sha + local reviewed_ref=refs/heads/master auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$token" | base64 -w0)" git init -q "$dir" git -C "$dir" remote add origin "https://github.com/${repo}.git" + if ! reviewed="$(git -C "$dir" \ + -c "http.https://github.com/.extraheader=$auth" \ + -c http.followRedirects=false \ + ls-remote --exit-code origin "$reviewed_ref")"; then + echo "::error::Could not resolve reviewed master for $label repository $repo" + exit 1 + fi + read -r reviewed_sha reviewed_name extra <<< "$reviewed" + if [[ ! "$reviewed_sha" =~ ^[0-9a-f]{40}$ ]] || \ + [ "$reviewed_name" != "$reviewed_ref" ] || [ -n "$extra" ]; then + echo "::error::$label reviewed ref identity did not match $reviewed_ref in $repo" + exit 1 + fi + comparison="$dir-reviewed-ancestry.json" + if ! curl --fail --silent --show-error --max-redirs 0 \ + --proto '=https' --tlsv1.2 \ + -H 'Accept: application/vnd.github+json' \ + -H "Authorization: Bearer $token" \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + -o "$comparison" \ + "https://api.github.com/repos/${repo}/compare/${sha}...${reviewed_sha}"; then + echo "::error::Could not verify that $label commit $sha is reachable from reviewed master in $repo" + exit 1 + fi + if ! jq -e --arg sha "$sha" ' + (.status == "ahead" or .status == "identical") and + .base_commit.sha == $sha and .merge_base_commit.sha == $sha + ' "$comparison" > /dev/null; then + echo "::error::$label commit $sha is not reachable from reviewed master in $repo" + exit 1 + fi if ! git -C "$dir" -c "http.https://github.com/.extraheader=$auth" \ -c http.followRedirects=false \ fetch -q --depth 1 origin "$sha"; then @@ -156,15 +209,27 @@ runs: git -C "$dir" checkout -q --detach FETCH_HEAD if [ "$(git -C "$dir" rev-parse HEAD)" != "$sha" ] || \ [ "$(git -C "$dir" remote get-url origin)" != "https://github.com/${repo}.git" ]; then - echo "::error::$label checkout identity did not match fixed repository $repo at $sha" + echo "::error::$label checkout identity did not match expected repository $repo at $sha" exit 1 fi } checkout_pinned "$publisher" "$publisher_repo" "$publisher_sha" "$PUBLISHER_TOKEN" publisher checkout_pinned "$source" "$source_repo" "$source_sha" "$SOURCE_TOKEN" "config source" + unset PUBLISHER_TOKEN SOURCE_TOKEN structural="$source/$SOURCE_ROOT" + if [ ! -f "$publisher/package.json" ] || \ + [ ! -f "$publisher/pnpm-lock.yaml" ] || \ + [ ! -f "$publisher/packages/config-publisher/package.json" ] || \ + [ ! -f "$publisher/packages/config-structural/schema/config.schema.json" ] || \ + [ -L "$publisher/package.json" ] || \ + [ -L "$publisher/pnpm-lock.yaml" ] || \ + [ -L "$publisher/packages/config-publisher/package.json" ] || \ + [ -L "$publisher/packages/config-structural/schema/config.schema.json" ]; then + echo "::error::Pinned publisher checkout does not satisfy the publisher/parser/schema contract" + exit 1 + fi if [ ! -f "$structural/brands.manifest.yaml" ] || \ [ ! -f "$structural/schema/config.schema.json" ]; then echo "::error::Pinned config source must contain source root $SOURCE_ROOT with its manifest and schema mirror; production root is intentionally unavailable until reviewed production data exists" diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index 63c915848..c6724fcf7 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -366,16 +366,30 @@ describe('release brand matrix workflow', () => { expect(action).not.toContain(appTokenAction); expect(action).not.toContain('github-app-private-key'); expect(action).not.toContain('BOT_APP_PRIVATE_KEY'); - expect(action).toContain('publisher_repo=arcboxlabs/linkcodehq'); - expect(action).toContain('source_repo=arcboxlabs/linkcode-config'); + expect(action).toContain('publisher-repository:'); + expect(action).toContain('publisher_repo="$PUBLISHER_REPO"'); + expect(action).toContain('source-repository:'); + expect(action).toContain('source_repo="$SOURCE_REPO"'); expect(action).toContain('default: "."'); expect(action).toContain('.|examples/acme-zenith)'); expect(action).toContain('cmp -s'); expect(action).toContain('http.followRedirects=false'); + expect(action).toContain('--max-redirs 0'); + expect(action).toContain('refs/heads/master'); + expect(action).toContain('not reachable from reviewed master'); expect(action).toContain('must be exact lowercase 40-hex commits'); expect(action).toContain('must not contain symbolic links'); - expect(action).not.toContain('CONFIG_PUBLISHER_REPO'); + expect(action).toContain('CONFIG_PUBLISHER_REPO'); + expect(action).toContain('CONFIG_SOURCE_REPO'); expect(action).not.toContain('CONFIG_PUBLISHER_TOKEN'); + expect(action).not.toContain('CONFIG_SOURCE_TOKEN'); + expect(action).toContain('checkout identity did not match expected repository'); + expect(action).toContain('publisher/parser/schema contract'); + expect(action).toContain('Pinned config source must contain source root'); + expect(action).toContain('unset PUBLISHER_TOKEN SOURCE_TOKEN'); + expect(action.indexOf('unset PUBLISHER_TOKEN SOURCE_TOKEN')).toBeLessThan( + action.indexOf('pnpm --dir "$publisher" install --frozen-lockfile'), + ); const renderJobs = [ desktop.slice(desktop.indexOf(' render-config:'), desktop.indexOf(' build:')), @@ -385,9 +399,29 @@ describe('release brand matrix workflow', () => { for (const renderJob of renderJobs) { expect(renderJob.split(appTokenAction)).toHaveLength(3); expect(renderJob.split('owner: arcboxlabs')).toHaveLength(3); - expect(renderJob).toContain('repositories: linkcodehq'); - expect(renderJob).toContain('repositories: linkcode-config'); + expect(renderJob).toContain( + `CONFIG_PUBLISHER_REPO: ${ACTIONS_EXPRESSION}{{ vars.CONFIG_PUBLISHER_REPO }}`, + ); + expect(renderJob).toContain( + `CONFIG_SOURCE_REPO: ${ACTIONS_EXPRESSION}{{ vars.CONFIG_SOURCE_REPO }}`, + ); + expect(renderJob).toContain('$name is required'); + expect(renderJob).toContain('must use canonical owner/repository syntax'); + expect(renderJob).toContain('$name owner must be arcboxlabs'); + expect(renderJob).toContain('must identify different repositories'); + expect(renderJob).toContain( + `repositories: ${ACTIONS_EXPRESSION}{{ steps.repositories.outputs.publisher-name }}`, + ); + expect(renderJob).toContain( + `repositories: ${ACTIONS_EXPRESSION}{{ steps.repositories.outputs.source-name }}`, + ); expect(renderJob.split('permission-contents: read')).toHaveLength(3); + expect(renderJob).toContain( + `publisher-repository: ${ACTIONS_EXPRESSION}{{ steps.repositories.outputs.publisher-full }}`, + ); + expect(renderJob).toContain( + `source-repository: ${ACTIONS_EXPRESSION}{{ steps.repositories.outputs.source-full }}`, + ); expect(renderJob).toContain( `publisher-token: ${ACTIONS_EXPRESSION}{{ steps.publisher-token.outputs.token }}`, ); @@ -397,10 +431,26 @@ describe('release brand matrix workflow', () => { expect(renderJob.indexOf(appTokenAction)).toBeLessThan( renderJob.indexOf('actions/checkout@'), ); + expect(renderJob).not.toContain('repositories: linkcodehq'); + expect(renderJob).not.toContain('repositories: linkcode-config'); } expect(workflow.split('source-root: examples/acme-zenith')).toHaveLength(3); }); + it('rejects publisher/source role swaps through independent checkout contracts', async () => { + const action = await readFile( + new URL('../actions/render-release-config/action.yml', import.meta.url), + 'utf8', + ); + + expect(action).toContain('$publisher/packages/config-publisher/package.json'); + expect(action).toContain('$publisher/packages/config-structural/schema/config.schema.json'); + expect(action).toContain('$structural/brands.manifest.yaml'); + expect(action).toContain('$structural/schema/config.schema.json'); + expect(action).toContain('publisher/parser/schema contract'); + expect(action).toContain('Pinned config source must contain source root'); + }); + it('binds credential-free desktop recovery evidence to immutable release inputs', async () => { const workflow = await readFile( new URL('../workflows/release-brand-matrix.yml', import.meta.url), diff --git a/.github/scripts/release-inputs.cjs b/.github/scripts/release-inputs.cjs index c138b6437..83bcff118 100644 --- a/.github/scripts/release-inputs.cjs +++ b/.github/scripts/release-inputs.cjs @@ -4,8 +4,11 @@ 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 RE_PUBLISHER_REPOSITORY = /^([a-z\d][a-z\d-]{0,38})\/[a-z\d][\w.-]{0,99}$/i; const INPUTS = { render: [ + ['var', 'CONFIG_PUBLISHER_REPO'], + ['var', 'CONFIG_SOURCE_REPO'], ['var', 'CONFIG_RELEASE_KEYRINGS'], ['var', 'CONFIG_RELEASE_REVISION'], ], @@ -56,6 +59,22 @@ function validateReleaseInputs({ env, phase, platform }) { `${phase}/${platform}: missing GitHub release environment inputs: ${formatted}`, ); } + if (phase === 'render') { + for (const name of ['CONFIG_PUBLISHER_REPO', 'CONFIG_SOURCE_REPO']) { + const repository = RE_PUBLISHER_REPOSITORY.exec(env[name]); + if (!repository) { + throw new TypeError(`render: var ${name} must use canonical owner/repository syntax`); + } + if (repository[1] !== 'arcboxlabs') { + throw new TypeError(`render: var ${name} owner must be arcboxlabs`); + } + } + if (env.CONFIG_PUBLISHER_REPO === env.CONFIG_SOURCE_REPO) { + throw new TypeError( + 'render: CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories', + ); + } + } if (phase === 'sign' && platform === 'desktop') { let key; try { diff --git a/.github/scripts/release-inputs.test.mjs b/.github/scripts/release-inputs.test.mjs index 1d8953c09..7be355065 100644 --- a/.github/scripts/release-inputs.test.mjs +++ b/.github/scripts/release-inputs.test.mjs @@ -2,12 +2,19 @@ import { describe, expect, it } from 'vitest'; import inputsModule from './release-inputs.cjs'; const { validateReleaseInputs } = inputsModule; -const RE_RENDER_MISSING = /var CONFIG_RELEASE_KEYRINGS.*var CONFIG_RELEASE_REVISION/; +const RE_RENDER_MISSING = + /var CONFIG_PUBLISHER_REPO.*var CONFIG_SOURCE_REPO.*var CONFIG_RELEASE_KEYRINGS.*var CONFIG_RELEASE_REVISION/; 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/; +const RE_CANONICAL_REPOSITORY = /must use canonical owner\/repository syntax/; +const RE_ARCBOXLABS_OWNER = /owner must be arcboxlabs/; +const RE_SOURCE_CANONICAL_REPOSITORY = + /CONFIG_SOURCE_REPO must use canonical owner\/repository syntax/; +const RE_SOURCE_ARCBOXLABS_OWNER = /CONFIG_SOURCE_REPO owner must be arcboxlabs/; +const RE_DIFFERENT_REPOSITORIES = /must identify different repositories/; describe('validateReleaseInputs', () => { it('reports absent render vars by exact GitHub name', () => { @@ -16,6 +23,76 @@ describe('validateReleaseInputs', () => { ); }); + it('rejects malformed and cross-organization config repositories', () => { + const renderEnv = { + CONFIG_PUBLISHER_REPO: 'arcboxlabs/config-publisher', + CONFIG_SOURCE_REPO: 'arcboxlabs/config-source', + CONFIG_RELEASE_KEYRINGS: '{}', + CONFIG_RELEASE_REVISION: '{}', + }; + expect(() => + validateReleaseInputs({ + env: { + ...renderEnv, + CONFIG_PUBLISHER_REPO: 'https://github.com/arcboxlabs/publisher', + }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_CANONICAL_REPOSITORY); + expect(() => + validateReleaseInputs({ + env: { ...renderEnv, CONFIG_PUBLISHER_REPO: 'another-org/config-publisher' }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_ARCBOXLABS_OWNER); + expect(() => + validateReleaseInputs({ + env: { ...renderEnv, CONFIG_SOURCE_REPO: 'arcboxlabs/source/extra' }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_SOURCE_CANONICAL_REPOSITORY); + expect(() => + validateReleaseInputs({ + env: { ...renderEnv, CONFIG_SOURCE_REPO: 'another-org/config-source' }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_SOURCE_ARCBOXLABS_OWNER); + }); + + it('accepts non-hardcoded publisher and source repositories in the ArcBox Labs organization', () => { + expect(() => + validateReleaseInputs({ + env: { + CONFIG_PUBLISHER_REPO: 'arcboxlabs/config-publisher', + CONFIG_SOURCE_REPO: 'arcboxlabs/config-source', + CONFIG_RELEASE_KEYRINGS: '{}', + CONFIG_RELEASE_REVISION: '{}', + }, + phase: 'render', + platform: 'desktop', + }), + ).not.toThrow(); + }); + + it('rejects equal publisher and source repository roles', () => { + expect(() => + validateReleaseInputs({ + env: { + CONFIG_PUBLISHER_REPO: 'arcboxlabs/config-repository', + CONFIG_SOURCE_REPO: 'arcboxlabs/config-repository', + CONFIG_RELEASE_KEYRINGS: '{}', + CONFIG_RELEASE_REVISION: '{}', + }, + phase: 'render', + platform: 'desktop', + }), + ).toThrow(RE_DIFFERENT_REPOSITORIES); + }); + it('requires signing and upload inputs only for the requested platform', () => { expect(() => validateReleaseInputs({ env: {}, phase: 'sign', platform: 'mobile' })).toThrow( RE_MOBILE_SIGNING, diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 4e3d7bc90..89ba8814d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -85,22 +85,52 @@ env: LINKCODE_REQUIRE_CONFIG_BUNDLE: ${{ (inputs.sign || inputs.rendered_artifact != '') && '1' || '' }} jobs: - # Renders the immutable config bundle from the pinned config publisher checkout (release - # environment holds the pins and the publisher read token). Unsigned builds skip this and - # build without a bundle; signed builds hard-require its output. + # Renders the immutable config bundle from the pinned config publisher checkout. The release + # environment selects the publisher repository; the workflow mints its scoped read token. + # Unsigned builds skip this and build without a bundle; signed builds hard-require its output. render-config: name: Render immutable config if: ${{ inputs.sign && inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: ${{ inputs.release_environment || 'release' }} steps: + - name: Validate config repositories + id: repositories + env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_SOURCE_REPO: ${{ vars.CONFIG_SOURCE_REPO }} + run: | + set -euo pipefail + validate_repository() { + local name="$1" value="$2" prefix="$3" + if [ -z "$value" ]; then + echo "::error::$name is required in the protected release environment" + exit 1 + fi + if [[ ! "$value" =~ ^([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9][A-Za-z0-9._-]{0,99})$ ]]; then + echo "::error::$name must use canonical owner/repository syntax" + exit 1 + fi + if [ "${BASH_REMATCH[1]}" != arcboxlabs ]; then + echo "::error::$name owner must be arcboxlabs" + exit 1 + fi + printf '%s-full=%s\n%s-name=%s\n' "$prefix" "$value" "$prefix" "${BASH_REMATCH[2]}" >> "$GITHUB_OUTPUT" + } + validate_repository CONFIG_PUBLISHER_REPO "$CONFIG_PUBLISHER_REPO" publisher + validate_repository CONFIG_SOURCE_REPO "$CONFIG_SOURCE_REPO" source + if [ "$CONFIG_PUBLISHER_REPO" = "$CONFIG_SOURCE_REPO" ]; then + echo "::error::CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories" + exit 1 + fi + - name: Require organization App credentials env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} run: | if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then - echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read the selected publisher and source repositories" exit 1 fi @@ -111,7 +141,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcodehq + repositories: ${{ steps.repositories.outputs.publisher-name }} permission-contents: read - name: Mint config source read token @@ -121,7 +151,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcode-config + repositories: ${{ steps.repositories.outputs.source-name }} permission-contents: read - name: Checkout @@ -147,7 +177,9 @@ jobs: uses: ./.github/actions/render-release-config with: app: desktop + publisher-repository: ${{ steps.repositories.outputs.publisher-full }} publisher-token: ${{ steps.publisher-token.outputs.token }} + source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 9a5bc0982..0d881d0c5 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -86,13 +86,43 @@ jobs: timeout-minutes: 20 environment: ${{ inputs.release_environment || 'release' }} steps: + - name: Validate config repositories + id: repositories + env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_SOURCE_REPO: ${{ vars.CONFIG_SOURCE_REPO }} + run: | + set -euo pipefail + validate_repository() { + local name="$1" value="$2" prefix="$3" + if [ -z "$value" ]; then + echo "::error::$name is required in the protected release environment" + exit 1 + fi + if [[ ! "$value" =~ ^([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9][A-Za-z0-9._-]{0,99})$ ]]; then + echo "::error::$name must use canonical owner/repository syntax" + exit 1 + fi + if [ "${BASH_REMATCH[1]}" != arcboxlabs ]; then + echo "::error::$name owner must be arcboxlabs" + exit 1 + fi + printf '%s-full=%s\n%s-name=%s\n' "$prefix" "$value" "$prefix" "${BASH_REMATCH[2]}" >> "$GITHUB_OUTPUT" + } + validate_repository CONFIG_PUBLISHER_REPO "$CONFIG_PUBLISHER_REPO" publisher + validate_repository CONFIG_SOURCE_REPO "$CONFIG_SOURCE_REPO" source + if [ "$CONFIG_PUBLISHER_REPO" = "$CONFIG_SOURCE_REPO" ]; then + echo "::error::CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories" + exit 1 + fi + - name: Require organization App credentials env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} run: | if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then - echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read the selected publisher and source repositories" exit 1 fi @@ -103,7 +133,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcodehq + repositories: ${{ steps.repositories.outputs.publisher-name }} permission-contents: read - name: Mint config source read token @@ -113,7 +143,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcode-config + repositories: ${{ steps.repositories.outputs.source-name }} permission-contents: read - name: Checkout @@ -132,7 +162,9 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile + publisher-repository: ${{ steps.repositories.outputs.publisher-full }} publisher-token: ${{ steps.publisher-token.outputs.token }} + source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 2cfe9cc57..8ba8c7c53 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -230,6 +230,8 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_SOURCE_REPO: ${{ vars.CONFIG_SOURCE_REPO }} CONFIG_RELEASE_KEYRINGS: ${{ vars.CONFIG_RELEASE_KEYRINGS }} CONFIG_RELEASE_REVISION: ${{ vars.CONFIG_RELEASE_REVISION }} steps: @@ -282,13 +284,43 @@ jobs: runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} environment: release steps: + - name: Validate config repositories + id: repositories + env: + CONFIG_PUBLISHER_REPO: ${{ vars.CONFIG_PUBLISHER_REPO }} + CONFIG_SOURCE_REPO: ${{ vars.CONFIG_SOURCE_REPO }} + run: | + set -euo pipefail + validate_repository() { + local name="$1" value="$2" prefix="$3" + if [ -z "$value" ]; then + echo "::error::$name is required in the protected release environment" + exit 1 + fi + if [[ ! "$value" =~ ^([A-Za-z0-9][A-Za-z0-9-]{0,38})/([A-Za-z0-9][A-Za-z0-9._-]{0,99})$ ]]; then + echo "::error::$name must use canonical owner/repository syntax" + exit 1 + fi + if [ "${BASH_REMATCH[1]}" != arcboxlabs ]; then + echo "::error::$name owner must be arcboxlabs" + exit 1 + fi + printf '%s-full=%s\n%s-name=%s\n' "$prefix" "$value" "$prefix" "${BASH_REMATCH[2]}" >> "$GITHUB_OUTPUT" + } + validate_repository CONFIG_PUBLISHER_REPO "$CONFIG_PUBLISHER_REPO" publisher + validate_repository CONFIG_SOURCE_REPO "$CONFIG_SOURCE_REPO" source + if [ "$CONFIG_PUBLISHER_REPO" = "$CONFIG_SOURCE_REPO" ]; then + echo "::error::CONFIG_PUBLISHER_REPO and CONFIG_SOURCE_REPO must identify different repositories" + exit 1 + fi + - name: Require organization App credentials env: BOT_APP_ID: ${{ secrets.BOT_APP_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} run: | if [ -z "$BOT_APP_ID" ] || [ -z "$BOT_APP_PRIVATE_KEY" ]; then - echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read arcboxlabs/linkcodehq and arcboxlabs/linkcode-config" + echo "::error::BOT_APP_ID and BOT_APP_PRIVATE_KEY must be available so config rendering can read the selected publisher and source repositories" exit 1 fi @@ -299,7 +331,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcodehq + repositories: ${{ steps.repositories.outputs.publisher-name }} permission-contents: read - name: Mint config source read token @@ -309,7 +341,7 @@ jobs: app-id: ${{ secrets.BOT_APP_ID }} private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} owner: arcboxlabs - repositories: linkcode-config + repositories: ${{ steps.repositories.outputs.source-name }} permission-contents: read - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -334,7 +366,9 @@ jobs: with: app: desktop brand-artifacts: true + publisher-repository: ${{ steps.repositories.outputs.publisher-full }} publisher-token: ${{ steps.publisher-token.outputs.token }} + source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} source-root: examples/acme-zenith revision: ${{ vars.CONFIG_RELEASE_REVISION }} @@ -345,7 +379,9 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile + publisher-repository: ${{ steps.repositories.outputs.publisher-full }} publisher-token: ${{ steps.publisher-token.outputs.token }} + source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} source-root: examples/acme-zenith revision: ${{ vars.CONFIG_RELEASE_REVISION }} diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index fa01d067a..a716bfc32 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -110,7 +110,7 @@ client configuration or new build. | `CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER` | `apps/desktop/scripts/stage-sidecar.mts` | `aarch64-linux-gnu-gcc` for the linux-arm64 sidecar cross-build. | | `NODE_OPTIONS` | `.github/workflows/ci.yml` | `--max-old-space-size=4096` for every CI job. | | `POSTHOG_HOST` | desktop/mobile build workflows | Organization Actions variable mapped to the platform-specific PostHog host for production bundles. | -| `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars containing exact revision/public-keyring JSON bytes; release manifests digest-bind the JSON inputs. Publisher and source repository identities are fixed in workflow code. | +| `CONFIG_PUBLISHER_REPO`, `CONFIG_SOURCE_REPO`, `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS` | release workflows | Protected `release` environment vars. The repository vars are different canonical `arcboxlabs/repository` identities for publisher code and structural source data; trusted steps validate them before minting separate repository-scoped read tokens. Current values are `arcboxlabs/linkcodehq` and `arcboxlabs/linkcode-config`. Release manifests bind their commits independently and digest-bind the exact revision/public-keyring JSON inputs. | ## Release-only secrets @@ -129,7 +129,7 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `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. | | `_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 and config-render workflows | Organization 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; install it on private `arcboxlabs/linkcodehq` and `arcboxlabs/linkcode-config` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only package-manager bumps remain an optional self-skip. | +| `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | release and config-render workflows | Organization 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; install it on the private repositories selected by `CONFIG_PUBLISHER_REPO` and `CONFIG_SOURCE_REPO` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only package-manager bumps remain an optional self-skip. | Mobile certificates, provisioning profiles, the Android keystore, the App Store Connect API key, and the Google Play service-account key are EAS-managed credentials, not GitHub variables. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index c7362dbcf..08871b301 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -89,21 +89,30 @@ Desktop signing and R2 secrets live in the repo's GitHub **`release` Environment ## Immutable config bundle (build-time render) -Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out publisher code from fixed `arcboxlabs/linkcodehq` at `publisherGitSha` and structural data from fixed `arcboxlabs/linkcode-config` at the independent `sourceGitSha`. It renders through `pnpm -F @linkcode/ config:render` and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, a configurable repository, a global install, or stale generated output. +Signed desktop builds and every mobile store build embed an immutable config bundle (bootstrap endpoints, public keyrings, bundled defaults) rendered at build time by the config publisher — the client never re-implements rendering. The `render-config` job in `build-desktop.yml` (signed builds only) and `build-mobile.yml` (always) calls `.github/actions/render-release-config`, which checks out publisher code from protected `CONFIG_PUBLISHER_REPO` at `publisherGitSha` and structural data from protected `CONFIG_SOURCE_REPO` at the independent `sourceGitSha`. It renders through `pnpm -F @linkcode/ config:render` and verifies the manifest's digest bindings (revision bytes, public keyring bytes, target identity, telemetry endpoint, expected snapshot SHA-256). Nothing falls back to a mutable ref, an unvalidated or cross-organization repository, a global install, or stale generated output. Each checkout uses its own short-lived installation token minted from the organization secrets `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY`. Trusted workflow steps mint these tokens before checking out the selected client ref; client-controlled actions receive only repository-scoped read tokens, never the App private key. Each token requests only Contents read and is explicitly limited to -`linkcodehq` or `linkcode-config`. The App must be installed on both private repositories. Missing -secrets or installation access fail before rendering; no long-lived config-read token is used. +the selected publisher or source repository. The App must be installed on both private +repositories. Missing secrets or installation access fail before rendering; no long-lived +config-read token is used. -Production rendering reads the root of `linkcode-config` and fails closed while production data is -absent. Workflow code may select only that root or the reviewed `examples/acme-zenith` root used by -the nonproduction pilot; configuration data cannot supply a repository or path. +Production rendering reads the root of `CONFIG_SOURCE_REPO` and fails closed while production data +is absent. Workflow code may select only that root or the reviewed `examples/acme-zenith` root used +by the nonproduction pilot; configuration data cannot supply a path. Inputs live in the GitHub **`release` environment** and a missing value fails the build with an actionable error: +- `CONFIG_PUBLISHER_REPO` (var) — exact canonical `owner/repository` identity for the publisher. + The current org-wide App contract requires owner `arcboxlabs`; the repository name is not + hardcoded. Trusted workflow steps validate and split this value before minting a token restricted + to that one repository. +- `CONFIG_SOURCE_REPO` (var) — exact canonical `owner/repository` identity for structural + manifest, layers, and assets. It has the same owner restriction and receives a separate token. + The two repository values must differ. Current values are `arcboxlabs/linkcodehq` and + `arcboxlabs/linkcode-config`, respectively. - `CONFIG_RELEASE_REVISION` / `CONFIG_RELEASE_KEYRINGS` (vars) — exact revision-metadata and public-keyrings JSON bytes; the manifest pins their SHA-256s, so drifted content fails closed. Public keys only — private keys never enter this repo or its CI. - `CONFIG_RELEASE_MANIFEST_DESKTOP` / `CONFIG_RELEASE_MANIFEST_IOS` / `CONFIG_RELEASE_MANIFEST_ANDROID` (vars) — release-render manifest v1 JSON per target (produced by the publisher's release flow), pinning `publisherGitSha`, `sourceGitSha`, brand/platform/channel, telemetry endpoint, input digests, and the expected published snapshot digest. @@ -172,12 +181,16 @@ credentials are organization secrets. Trusted workflow steps report missing bot checking out selected client code, and the input scripts report missing render, signing, or upload values without receiving those bot credentials: -- Vars: `CONFIG_RELEASE_REVISION`, `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. +- Vars: `CONFIG_PUBLISHER_REPO`, `CONFIG_SOURCE_REPO`, `CONFIG_RELEASE_REVISION`, + `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. Both repository vars must be canonical, + different `arcboxlabs/repository` identities; malformed, absent, cross-organization, and equal + values fail before token minting or checkout. Revision/keyring values are exact JSON bytes already digest-pinned by each release manifest. - Config checkouts: organization secrets `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY` mint separate, - short-lived installation tokens with **Contents: read** only on `arcboxlabs/linkcodehq` and - `arcboxlabs/linkcode-config`. The workflow fixes both repository identities and requests no write - or organization permission. + short-lived installation tokens with **Contents: read** only on the validated publisher + and source repositories. The workflow requests no write or organization permission and never + passes the App private key to selected code. Each exact commit must be reachable from that + repository's reviewed `master` branch before its role-specific contract is accepted. - 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. From e626d57cba8d7ab21cd6814a1514d9bcc2b7deaf Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 03:32:49 +0000 Subject: [PATCH 20/21] fix(release): close privileged workflow paths Amp-Thread-ID: https://ampcode.com/threads/T-019feb48-7ae2-72cf-85f9-4a9ce1de64eb --- .github/actions/setup-eas/action.yml | 6 +- .../brand-matrices/code-561-pilot.json | 6 +- .github/scripts/brand-matrix.cjs | 32 ++-- .github/scripts/brand-matrix.test.mjs | 69 +++++--- .github/workflows/release-brand-matrix.yml | 152 +++++++++++------- docs/ENVIRONMENT.md | 2 +- docs/RELEASE.md | 47 +++--- 7 files changed, 193 insertions(+), 121 deletions(-) diff --git a/.github/actions/setup-eas/action.yml b/.github/actions/setup-eas/action.yml index 54a526e39..ea6cfada3 100644 --- a/.github/actions/setup-eas/action.yml +++ b/.github/actions/setup-eas/action.yml @@ -2,6 +2,10 @@ name: Setup EAS description: Setup the repository Node and pnpm toolchain with a pinned EAS CLI inputs: + cache: + description: Cache the pnpm store + required: false + default: "true" eas-version: description: EAS CLI version required: false @@ -14,7 +18,7 @@ runs: uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: run_install: false - cache: true + cache: ${{ inputs.cache }} - name: Setup Node uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 diff --git a/.github/release/brand-matrices/code-561-pilot.json b/.github/release/brand-matrices/code-561-pilot.json index bd1585960..49bb36147 100644 --- a/.github/release/brand-matrices/code-561-pilot.json +++ b/.github/release/brand-matrices/code-561-pilot.json @@ -98,7 +98,8 @@ "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://acme.example.invalid/telemetry" } - } + }, + "sourceRoot": "examples/acme-zenith" }, { "brandId": "zenith", @@ -197,7 +198,8 @@ "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", "telemetryEndpoint": "https://zenith.example.invalid/telemetry" } - } + }, + "sourceRoot": "examples/acme-zenith" } ] } diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs index 1f135ef13..544df61c0 100644 --- a/.github/scripts/brand-matrix.cjs +++ b/.github/scripts/brand-matrix.cjs @@ -34,7 +34,6 @@ 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}`); @@ -126,13 +125,15 @@ function compliance(value, path) { function desktopDistribution(value, path, brandId, channel) { if (value === null) return null; const distribution = record(value, path); - exact(distribution, ['credentialSecretPrefix', 'r2Bucket', 'r2Prefix', 'updateUrl'], path); + exact(distribution, ['credentialEnvironment', 'r2Bucket', 'r2Prefix', 'updateUrl'], path); const updateUrl = httpsUrl(distribution.updateUrl, `${path}.updateUrl`); - const credentialSecretPrefix = string( - distribution.credentialSecretPrefix, - `${path}.credentialSecretPrefix`, - RE_SECRET_PREFIX, + const credentialEnvironment = string( + distribution.credentialEnvironment, + `${path}.credentialEnvironment`, ); + if (credentialEnvironment !== `release-${brandId}`) { + fail(`${path}.credentialEnvironment`, `must equal release-${brandId}`); + } 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, '')}`; @@ -143,7 +144,7 @@ function desktopDistribution(value, path, brandId, channel) { fail(path, 'updateUrl path must end with r2Prefix'); } return { - credentialSecretPrefix, + credentialEnvironment, r2Bucket, r2Prefix: r2Prefix.replace(RE_TRAILING_SLASH, ''), updateUrl, @@ -182,19 +183,25 @@ function parseBrandBuildMatrix(value, options = {}) { 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); + exact( + brand, + ['brandId', 'channel', 'compliance', 'distribution', 'releaseManifests', 'sourceRoot'], + 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'); } + if (brand.sourceRoot !== '.' && brand.sourceRoot !== 'examples/acme-zenith') { + fail(`${path}.sourceRoot`, 'must be . or examples/acme-zenith'); + } const manifests = record(brand.releaseManifests, `${path}.releaseManifests`); exact(manifests, PLATFORMS, `${path}.releaseManifests`); const declarations = record(brand.compliance, `${path}.compliance`); @@ -247,12 +254,6 @@ function parseBrandBuildMatrix(value, options = {}) { 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'); } @@ -264,7 +265,6 @@ function parseBrandBuildMatrix(value, options = {}) { bucket: distribution.desktop.r2Bucket, prefix: distribution.desktop.r2Prefix, }); - credentialPrefixes.add(distribution.desktop.credentialSecretPrefix); } if (distribution.mobile) { projects.add(distribution.mobile.easProjectId); diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index c6724fcf7..cf03e20ea 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -17,8 +17,10 @@ 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_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal release-/; const RE_SHARED_APP_STORE_APP = /ios\.ascAppId: must be unique/; +const RE_INVALID_SOURCE_ROOT = /sourceRoot: must be/; +const RE_SECRETS_EXPRESSION = /secrets(?:\.|\[)/; const ACTIONS_EXPRESSION = String.fromCodePoint(36); function sha(character) { @@ -74,6 +76,7 @@ function brand(brandId = 'acme') { desktop: manifest(brandId, 'desktop'), ios: manifest(brandId, 'ios'), }, + sourceRoot: '.', }; } @@ -115,6 +118,14 @@ describe('parseBrandBuildMatrix', () => { expect( pilot.brands.every((entry) => Object.values(entry.distribution).every((x) => x === null)), ).toBe(true); + expect(pilot.brands.every((entry) => entry.sourceRoot === 'examples/acme-zenith')).toBe(true); + expect(() => buildMatrixPlan(pilot, { build: true })).toThrow(RE_MISSING_DELIVERY); + const fixture = await readFile( + new URL('../../apps/desktop/e2e/fixtures/pilot-e2e-v1.json', import.meta.url), + ); + expect(createHash('sha256').update(fixture).digest('hex')).toBe( + '54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39', + ); }); it('builds the complete brand by platform plan', () => { @@ -166,7 +177,7 @@ describe('parseBrandBuildMatrix', () => { const first = brand('acme'); first.distribution.desktop = { - credentialSecretPrefix: 'ACME', + credentialEnvironment: 'release-acme', r2Bucket: 'release-acme', r2Prefix: 'desktop/acme/canary', updateUrl: 'https://acme.example.invalid/desktop/acme/canary', @@ -179,6 +190,7 @@ describe('parseBrandBuildMatrix', () => { }; const second = structuredClone(first); second.brandId = 'zenith'; + second.distribution.desktop.credentialEnvironment = 'release-zenith'; for (const platform of ['desktop', 'ios', 'android']) { second.releaseManifests[platform].brandId = 'zenith'; } @@ -190,7 +202,7 @@ describe('parseBrandBuildMatrix', () => { it('rejects shared R2 destinations, credentials, and store apps across brands', () => { const first = brand('acme'); first.distribution.desktop = { - credentialSecretPrefix: 'ACME', + credentialEnvironment: 'release-acme', r2Bucket: 'release-brands', r2Prefix: 'desktop/acme/zenith/canary', updateUrl: 'https://acme.example.invalid/desktop/acme/zenith/canary', @@ -203,7 +215,7 @@ describe('parseBrandBuildMatrix', () => { }; const second = brand('zenith'); second.distribution.desktop = { - credentialSecretPrefix: 'ZENITH', + credentialEnvironment: 'release-zenith', r2Bucket: first.distribution.desktop.r2Bucket, r2Prefix: first.distribution.desktop.r2Prefix, updateUrl: 'https://zenith.example.invalid/desktop/acme/zenith/canary', @@ -227,12 +239,12 @@ describe('parseBrandBuildMatrix', () => { second.distribution.desktop.r2Prefix = 'desktop/zenith/canary'; second.distribution.desktop.updateUrl = 'https://zenith.example.invalid/desktop/zenith/canary'; - second.distribution.desktop.credentialSecretPrefix = 'ACME'; + second.distribution.desktop.credentialEnvironment = 'release-acme'; expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( - RE_SHARED_CREDENTIALS, + RE_WRONG_CREDENTIAL_ENVIRONMENT, ); - second.distribution.desktop.credentialSecretPrefix = 'ZENITH'; + second.distribution.desktop.credentialEnvironment = 'release-zenith'; second.distribution.mobile.ios.ascAppId = first.distribution.mobile.ios.ascAppId; expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( RE_SHARED_APP_STORE_APP, @@ -247,6 +259,10 @@ describe('parseBrandBuildMatrix', () => { const divergent = matrix(brand()); divergent.brands[0].releaseManifests.ios.sourceGitSha = gitSha('f'); expect(() => parseBrandBuildMatrix(divergent)).toThrow(RE_DIVERGENT_SOURCE); + + const redirected = matrix(brand()); + redirected.brands[0].sourceRoot = 'brands/acme'; + expect(() => parseBrandBuildMatrix(redirected)).toThrow(RE_INVALID_SOURCE_ROOT); }); it('emits the digest of the exact matrix-file bytes', async () => { @@ -275,9 +291,7 @@ describe('release brand matrix workflow', () => { ); expect(validation).toContain('needs: prepare'); - expect(validation).toContain( - `matrix: ${ACTIONS_EXPRESSION}{{ fromJSON(needs.prepare.outputs.targets) }}`, - ); + expect(validation).toContain('platform: [desktop, ios, android]'); expect(validation).toContain('xvfb-run -a pnpm -F @linkcode/desktop e2e:config-canary'); expect(validation).toContain('pnpm -F @linkcode/mobile smoke:export'); expect(validation).toContain( @@ -285,13 +299,12 @@ describe('release brand matrix workflow', () => { ); expect(validation).toContain("matrix.platform == 'desktop'"); expect(validation).toContain("matrix.platform != 'desktop'"); - expect(validation).toContain( - `credential-free-${ACTIONS_EXPRESSION}{{ matrix.brandId }}-${ACTIONS_EXPRESSION}{{ matrix.platform }}`, - ); + expect(validation).toContain(`credential-free-${ACTIONS_EXPRESSION}{{ matrix.platform }}`); expect(validation).toContain('"local-static-origin"'); expect(validation).toContain('providerDeploymentId:null'); expect(validation).not.toContain('environment: release'); - expect(validation).not.toContain('secrets.'); + expect(validation).not.toMatch(RE_SECRETS_EXPRESSION); + expect(validation).not.toContain('brandId:$brandId'); expect(validation).not.toContain('release-environment-preflight'); }); @@ -309,7 +322,12 @@ describe('release brand matrix workflow', () => { expect(preflight).toContain('protection_rules'); expect(preflight).toContain('required_reviewers'); expect(preflight).toContain('deployment_branch_policy'); - expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/release"'); + expect(preflight).toContain('gh api "repos/$GITHUB_REPOSITORY/environments/$name"'); + expect(preflight).toContain('deployment-branch-policies?per_page=100'); + expect(preflight).toContain( + 'expected=\'[{"name":"master","type":"branch"},{"name":"v*.*.*","type":"tag"}]\'', + ); + expect(preflight).toContain('credentialEnvironment'); expect(preflight).toContain(`GH_TOKEN: ${ACTIONS_EXPRESSION}{{ github.token }}`); expect(preflight).not.toContain('RELEASE_ENVIRONMENT_ADMIN_TOKEN'); expect(workflow).toContain('actions: read'); @@ -325,9 +343,20 @@ describe('release brand matrix workflow', () => { workflow.indexOf(' render:'), ); expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); - expect(signingInputs).toContain('environment: release'); - expect(workflow.split(' environment: release')).toHaveLength(7); - expect(workflow.split('release_environment: release')).toHaveLength(3); + expect(signingInputs).toContain( + `environment: ${ACTIONS_EXPRESSION}{{ matrix.distribution.desktop.credentialEnvironment }}`, + ); + expect(workflow).not.toContain('secrets[format('); + expect(workflow).toContain( + `R2_ACCESS_KEY_ID: ${ACTIONS_EXPRESSION}{{ secrets.R2_ACCESS_KEY_ID }}`, + ); + expect(workflow).toContain( + `release_environment: ${ACTIONS_EXPRESSION}{{ matrix.distribution.desktop.credentialEnvironment }}`, + ); + expect(workflow).toContain( + `if: ${ACTIONS_EXPRESSION}{{ inputs.build && !cancelled() && needs.render.result == 'success' && (needs.signing-inputs.result == 'success' || (!inputs.sign && needs.signing-inputs.result == 'skipped')) }}`, + ); + expect(workflow).not.toContain(`ref: ${ACTIONS_EXPRESSION}{{ inputs.ref }}`); }); it('passes the release environment through reusable signing workflows', async () => { @@ -434,7 +463,9 @@ describe('release brand matrix workflow', () => { expect(renderJob).not.toContain('repositories: linkcodehq'); expect(renderJob).not.toContain('repositories: linkcode-config'); } - expect(workflow.split('source-root: examples/acme-zenith')).toHaveLength(3); + expect( + workflow.split(`source-root: ${ACTIONS_EXPRESSION}{{ matrix.sourceRoot }}`), + ).toHaveLength(3); }); it('rejects publisher/source role swaps through independent checkout contracts', async () => { diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 8ba8c7c53..839197680 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -34,7 +34,7 @@ on: default: false concurrency: - group: release-brand-matrix-${{ inputs.ref }} + group: release-brand-matrix-${{ github.sha }} cancel-in-progress: false permissions: @@ -85,12 +85,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} fetch-depth: 0 - name: Verify trusted client checkout env: - CLIENT_REF: ${{ inputs.ref }} + CLIENT_REF: ${{ github.sha }} MATRIX_FILE: ${{ inputs.matrix_file }} run: | set -euo pipefail @@ -128,20 +128,21 @@ jobs: --upload "${{ inputs.upload }}" credential-free-validation: - name: Credential-free ${{ matrix.brandId }}/${{ matrix.platform }} + name: Credential-free ${{ matrix.platform }} needs: prepare runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} strategy: fail-fast: false - matrix: ${{ fromJSON(needs.prepare.outputs.targets) }} + matrix: + platform: [desktop, ios, android] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: run_install: false - cache: true + cache: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version-file: .nvmrc @@ -174,8 +175,7 @@ jobs: fi - name: Record local-only evidence env: - BRAND_ID: ${{ matrix.brandId }} - CLIENT_REF: ${{ inputs.ref }} + CLIENT_REF: ${{ github.sha }} DELIVERY_SHA256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} PLATFORM: ${{ matrix.platform }} run: | @@ -186,16 +186,15 @@ jobs: runtime=production-hermes+prebuild fi jq -cn \ - --arg brandId "$BRAND_ID" \ --arg clientGitSha "$CLIENT_REF" \ --arg deliveryDescriptorSha256 "$DELIVERY_SHA256" \ --arg platform "$PLATFORM" \ --arg runtime "$runtime" \ - '{brandId:$brandId,clientGitSha:$clientGitSha,deliveryDescriptorSha256:$deliveryDescriptorSha256,deploymentIdentity:{kind:"local-static-origin",providerDeploymentId:null},evidenceVersion:1,pilotFixtureSha256:"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39",platform:$platform,runtime:$runtime}' \ + '{clientGitSha:$clientGitSha,deliveryDescriptorSha256:$deliveryDescriptorSha256,deploymentIdentity:{kind:"local-static-origin",providerDeploymentId:null},evidenceVersion:1,pilotFixtureSha256:"54ce1fc855e12295a8dd1490463c9afac8e84a526f1e16340bcefe4f0fec8e39",platform:$platform,runtime:$runtime}' \ > credential-free-evidence/runtime-validation.json - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: credential-free-${{ matrix.brandId }}-${{ matrix.platform }} + name: credential-free-${{ matrix.platform }} path: credential-free-evidence if-no-files-found: error retention-days: 7 @@ -208,20 +207,41 @@ jobs: steps: - name: Require protected release environment env: + BRANDS_JSON: ${{ needs.prepare.outputs.brands }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - environment="$RUNNER_TEMP/release-environment.json" - gh api "repos/$GITHUB_REPOSITORY/environments/release" > "$environment" - if ! jq -e ' - .name == "release" and - ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers | length] | any(. > 0)) and - (.deployment_branch_policy != null) and - (.deployment_branch_policy.protected_branches == true or .deployment_branch_policy.custom_branch_policies == true) - ' "$environment" >/dev/null; then - echo "::error::release must require reviewers and a deployment branch policy before release work" - exit 1 - fi + check_environment() { + local name="$1" allow_tags="$2" + local metadata="$RUNNER_TEMP/environment-${name}.json" + local policies="$RUNNER_TEMP/environment-${name}-policies.json" + gh api "repos/$GITHUB_REPOSITORY/environments/$name" > "$metadata" + if ! jq -e --arg name "$name" ' + .name == $name and + ([.protection_rules[]? | select(.type == "required_reviewers") | .reviewers | length] | any(. > 0)) and + .deployment_branch_policy.protected_branches == false and + .deployment_branch_policy.custom_branch_policies == true + ' "$metadata" >/dev/null; then + echo "::error::$name must require reviewers and exact custom deployment policies" + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/environments/$name/deployment-branch-policies?per_page=100" > "$policies" + if [ "$allow_tags" = true ]; then + expected='[{"name":"master","type":"branch"},{"name":"v*.*.*","type":"tag"}]' + else + expected='[{"name":"master","type":"branch"}]' + fi + if ! jq -e --argjson expected "$expected" \ + '[.branch_policies[] | {name,type}] | sort_by(.type,.name) == $expected' \ + "$policies" >/dev/null; then + echo "::error::$name has an unexpected deployment branch or tag policy" + exit 1 + fi + } + check_environment release true + while IFS= read -r environment; do + check_environment "$environment" false + done < <(jq -r '.include[].distribution.desktop.credentialEnvironment' <<<"$BRANDS_JSON") render-inputs: name: Validate immutable render inputs @@ -237,15 +257,18 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} - run: node .github/scripts/release-inputs.cjs --phase render --platform desktop signing-inputs: - name: Validate signing inputs + name: Validate signing inputs ${{ matrix.brandId }} if: ${{ inputs.sign }} needs: [prepare, release-environment-preflight] runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: ${{ matrix.distribution.desktop.credentialEnvironment }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} env: APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} @@ -268,7 +291,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} - 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 }} @@ -346,13 +369,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} persist-credentials: false - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: run_install: false - cache: true + cache: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: @@ -370,7 +393,7 @@ jobs: publisher-token: ${{ steps.publisher-token.outputs.token }} source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} - source-root: examples/acme-zenith + source-root: ${{ matrix.sourceRoot }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest: ${{ toJSON(matrix.releaseManifests.desktop) }} @@ -383,7 +406,7 @@ jobs: publisher-token: ${{ steps.publisher-token.outputs.token }} source-repository: ${{ steps.repositories.outputs.source-full }} source-token: ${{ steps.source-token.outputs.token }} - source-root: examples/acme-zenith + source-root: ${{ matrix.sourceRoot }} revision: ${{ vars.CONFIG_RELEASE_REVISION }} keyrings: ${{ vars.CONFIG_RELEASE_KEYRINGS }} release-manifest-ios: ${{ toJSON(matrix.releaseManifests.ios) }} @@ -394,17 +417,18 @@ jobs: COMPLIANCE_ANDROID: ${{ toJSON(matrix.compliance.android) }} COMPLIANCE_DESKTOP: ${{ toJSON(matrix.compliance.desktop) }} COMPLIANCE_IOS: ${{ toJSON(matrix.compliance.ios) }} - CLIENT_REF: ${{ inputs.ref }} + CLIENT_REF: ${{ github.sha }} MATRIX_FILE: ${{ inputs.matrix_file }} MANIFEST_ANDROID: ${{ toJSON(matrix.releaseManifests.android) }} MANIFEST_DESKTOP: ${{ toJSON(matrix.releaseManifests.desktop) }} MANIFEST_IOS: ${{ toJSON(matrix.releaseManifests.ios) }} MOBILE_DISTRIBUTION: ${{ toJSON(matrix.distribution.mobile) }} + SOURCE_ROOT: ${{ matrix.sourceRoot }} run: | set -euo pipefail mkdir release-inputs git cat-file blob "$CLIENT_REF:$MATRIX_FILE" > release-inputs/brand-build-matrix.json - cp "$RUNNER_TEMP/config-render-desktop/source/examples/acme-zenith/brands.manifest.yaml" release-inputs/ + cp "$RUNNER_TEMP/config-render-desktop/source/$SOURCE_ROOT/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 @@ -441,7 +465,7 @@ jobs: --bundle "$bundle" \ --brand-identity "$identity" \ --brand-manifest release-inputs/brands.manifest.yaml \ - --client-git-sha '${{ inputs.ref }}' \ + --client-git-sha '${{ github.sha }}' \ --delivery-descriptor release-inputs/brand-build-matrix.json \ --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ @@ -464,7 +488,7 @@ jobs: desktop: name: Desktop ${{ matrix.brandId }} - if: ${{ inputs.build && !cancelled() && needs.render.result == 'success' && (needs.signing-inputs.result == 'success' || needs.signing-inputs.result == 'skipped') }} + if: ${{ inputs.build && !cancelled() && needs.render.result == 'success' && (needs.signing-inputs.result == 'success' || (!inputs.sign && needs.signing-inputs.result == 'skipped')) }} needs: [prepare, render, signing-inputs] strategy: fail-fast: false @@ -474,11 +498,11 @@ jobs: contents: read id-token: write with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} sign: ${{ inputs.sign }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} - release_environment: release + release_environment: ${{ matrix.distribution.desktop.credentialEnvironment }} rendered_artifact: brand-render-${{ matrix.brandId }} update_url: ${{ matrix.distribution.desktop.updateUrl || '' }} @@ -493,11 +517,11 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: run_install: false - cache: true + cache: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version-file: .nvmrc @@ -538,7 +562,7 @@ jobs: --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 }}' \ + --client-git-sha '${{ github.sha }}' \ --delivery-descriptor release-inputs/brand-build-matrix.json \ --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --release-manifest release-inputs/release-manifest.desktop.json \ @@ -562,8 +586,10 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} - uses: ./.github/actions/setup-eas + with: + cache: false - run: pnpm install --frozen-lockfile - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -591,7 +617,7 @@ jobs: --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 }}' \ + --client-git-sha '${{ github.sha }}' \ --delivery-descriptor release-inputs/brand-build-matrix.json \ --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --release-manifest "release-inputs/release-manifest.${platform}.json" \ @@ -614,10 +640,10 @@ jobs: matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} uses: ./.github/workflows/build-mobile.yml with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} brand_id: ${{ matrix.brandId }} delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} - release_environment: release + release_environment: ${{ matrix.distribution.desktop.credentialEnvironment }} rendered_artifact: brand-render-${{ matrix.brandId }} submit: false @@ -629,20 +655,20 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: ${{ matrix.distribution.desktop.credentialEnvironment }} 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)] }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: run_install: false - cache: true + cache: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version-file: .nvmrc @@ -677,7 +703,7 @@ jobs: --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 }}' \ + --client-git-sha '${{ github.sha }}' \ --delivery-descriptor release-inputs/brand-build-matrix.json \ --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ @@ -692,7 +718,7 @@ jobs: --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 }}' \ + --client-git-sha '${{ github.sha }}' \ --delivery-descriptor release-inputs/brand-build-matrix.json \ --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ @@ -709,12 +735,14 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: ${{ matrix.distribution.desktop.credentialEnvironment }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} - uses: ./.github/actions/setup-eas + with: + cache: false - run: pnpm install --frozen-lockfile - name: Validate upload inputs env: @@ -742,7 +770,7 @@ jobs: --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 }}' \ + --client-git-sha '${{ github.sha }}' \ --delivery-descriptor release-inputs/brand-build-matrix.json \ --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ @@ -785,19 +813,19 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.brands) }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + environment: ${{ matrix.distribution.desktop.credentialEnvironment }} 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)] }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: - ref: ${{ inputs.ref }} + ref: ${{ github.sha }} - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 with: run_install: false - cache: true + cache: false - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version-file: .nvmrc @@ -828,7 +856,7 @@ jobs: --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 }}' \ + --client-git-sha '${{ github.sha }}' \ --delivery-descriptor release-inputs/brand-build-matrix.json \ --expected-delivery-sha256 '${{ needs.prepare.outputs.delivery_descriptor_sha256 }}' \ --expected-brand '${{ matrix.brandId }}' \ diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index a716bfc32..eb1774103 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -128,7 +128,7 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `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. | -| `_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. | +| `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | Per-brand R2 account and S3 credentials in the matrix row's exact `release-` Environment. Scope each key pair to only that row's bucket/prefix with object read/write/list; never share one credential Environment between brands. | | `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | release and config-render workflows | Organization 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; install it on the private repositories selected by `CONFIG_PUBLISHER_REPO` and `CONFIG_SOURCE_REPO` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only 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 08871b301..792618599 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -140,7 +140,9 @@ the canonical schema in the pinned publisher checkout before parsing. Acme and Z `.invalid` endpoints, and this example root are not production brand data. The JSON root contains `brandBuildMatrixVersion: 1` and a non-empty `brands` array. Every brand has -exactly `brandId`, `channel`, `releaseManifests`, `compliance`, and `distribution`: +exactly `brandId`, `channel`, `sourceRoot`, `releaseManifests`, `compliance`, and `distribution`. +`sourceRoot` is either `.` for reviewed production data or `examples/acme-zenith` for the pinned +nonproduction fixture; no other path is accepted. - `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 @@ -149,8 +151,9 @@ exactly `brandId`, `channel`, `releaseManifests`, `compliance`, and `distributio 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. + `credentialEnvironment`, `r2Bucket`, `r2Prefix`, and `updateUrl`. The environment must be exactly + `release-`. Both URL and prefix must end in the same brand/channel path, and prefixes in + one bucket must not overlap. - `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. @@ -176,15 +179,16 @@ upload inputs before any store submission or R2 upload can begin. ### Required Actions configuration and least privilege -Signing secrets and render vars below are read from the protected `release` environment; the bot -credentials are organization secrets. Trusted workflow steps report missing bot credentials before -checking out selected client code, and the input scripts report missing render, signing, or upload -values without receiving those bot credentials: +Render vars below are read from the protected `release` environment. Signing, upload, store, and +observability inputs are read from the protected `release-` environment selected by the +reviewed matrix. The bot credentials are organization secrets. Trusted workflow steps report +missing bot credentials before checking out selected client code, and the input scripts report +missing render, signing, or upload values without receiving those bot credentials: -- Vars: `CONFIG_PUBLISHER_REPO`, `CONFIG_SOURCE_REPO`, `CONFIG_RELEASE_REVISION`, - `CONFIG_RELEASE_KEYRINGS`, and `POSTHOG_HOST`. Both repository vars must be canonical, - different `arcboxlabs/repository` identities; malformed, absent, cross-organization, and equal - values fail before token minting or checkout. +- `release` vars: `CONFIG_PUBLISHER_REPO`, `CONFIG_SOURCE_REPO`, `CONFIG_RELEASE_REVISION`, and + `CONFIG_RELEASE_KEYRINGS`. Both repository vars must be canonical, different + `arcboxlabs/repository` identities; malformed, absent, cross-organization, and equal values fail + before token minting or checkout. Revision/keyring values are exact JSON bytes already digest-pinned by each release manifest. - Config checkouts: organization secrets `BOT_APP_ID` and `BOT_APP_PRIVATE_KEY` mint separate, short-lived installation tokens with **Contents: read** only on the validated publisher @@ -197,9 +201,10 @@ values without receiving those bot credentials: - 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. + to this repository's matching `release-` environment; no client secret exists. +- Desktop observability in `release-`: `SENTRY_DSN_DESKTOP` and + `POSTHOG_PROJECT_TOKEN` plus the `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 @@ -207,15 +212,17 @@ values without receiving those bot credentials: 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 +- Desktop upload: `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, and `R2_SECRET_ACCESS_KEY` in each brand's + `release-` environment. 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 the committed -matrix, repository files, artifacts, or Actions vars. Protect the `release` environment with -required reviewers and exact deployment ref rules before enabling `sign` or `upload`. +matrix, repository files, artifacts, or Actions vars. Protect `release` with required reviewers and +only exact `master` plus `v*.*.*` custom deployment policies. Protect every `release-` +environment with required reviewers and only the exact `master` custom deployment policy before +enabling `build`, `sign`, or `upload`. The environment preflight reads protection metadata with the built-in `GITHUB_TOKEN` and explicit `actions: read`; this metadata-only token cannot approve or bypass an environment review. From 5a0c4dbb87c41469b9f03ba6ebdc58792ab71968 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 04:04:51 +0000 Subject: [PATCH 21/21] fix(release): unify target identity contract --- .github/scripts/brand-matrix.cjs | 4 +-- .github/scripts/brand-matrix.test.mjs | 16 +++++------ .github/workflows/release-brand-matrix.yml | 3 --- .../__tests__/electron-builder-brand.test.ts | 14 ++++++++++ .../src/build/__tests__/expo-brand.test.ts | 22 +++++++++++++++ docs/ENVIRONMENT.md | 2 +- docs/RELEASE.md | 27 +++++++++---------- 7 files changed, 60 insertions(+), 28 deletions(-) diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs index 544df61c0..d79b9582c 100644 --- a/.github/scripts/brand-matrix.cjs +++ b/.github/scripts/brand-matrix.cjs @@ -131,8 +131,8 @@ function desktopDistribution(value, path, brandId, channel) { distribution.credentialEnvironment, `${path}.credentialEnvironment`, ); - if (credentialEnvironment !== `release-${brandId}`) { - fail(`${path}.credentialEnvironment`, `must equal release-${brandId}`); + if (credentialEnvironment !== 'release') { + fail(`${path}.credentialEnvironment`, 'must equal release'); } const r2Bucket = string(distribution.r2Bucket, `${path}.r2Bucket`, RE_BUCKET); const r2Prefix = string(distribution.r2Prefix, `${path}.r2Prefix`, RE_R2_PREFIX); diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index cf03e20ea..c5a0bdaba 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -17,7 +17,7 @@ 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_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal release-/; +const RE_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal release/; const RE_SHARED_APP_STORE_APP = /ios\.ascAppId: must be unique/; const RE_INVALID_SOURCE_ROOT = /sourceRoot: must be/; const RE_SECRETS_EXPRESSION = /secrets(?:\.|\[)/; @@ -177,7 +177,7 @@ describe('parseBrandBuildMatrix', () => { const first = brand('acme'); first.distribution.desktop = { - credentialEnvironment: 'release-acme', + credentialEnvironment: 'release', r2Bucket: 'release-acme', r2Prefix: 'desktop/acme/canary', updateUrl: 'https://acme.example.invalid/desktop/acme/canary', @@ -188,9 +188,9 @@ describe('parseBrandBuildMatrix', () => { ios: { appleTeamId: 'ABC1234567', ascAppId: '1234567890' }, updatesUrl: 'https://u.expo.dev/11111111-1111-4111-8111-111111111111', }; + expect(() => parseBrandBuildMatrix(matrix(first), { build: true })).not.toThrow(); const second = structuredClone(first); second.brandId = 'zenith'; - second.distribution.desktop.credentialEnvironment = 'release-zenith'; for (const platform of ['desktop', 'ios', 'android']) { second.releaseManifests[platform].brandId = 'zenith'; } @@ -199,10 +199,10 @@ describe('parseBrandBuildMatrix', () => { ); }); - it('rejects shared R2 destinations, credentials, and store apps across brands', () => { + it('rejects shared R2 destinations and store apps across brands', () => { const first = brand('acme'); first.distribution.desktop = { - credentialEnvironment: 'release-acme', + credentialEnvironment: 'release', r2Bucket: 'release-brands', r2Prefix: 'desktop/acme/zenith/canary', updateUrl: 'https://acme.example.invalid/desktop/acme/zenith/canary', @@ -215,7 +215,7 @@ describe('parseBrandBuildMatrix', () => { }; const second = brand('zenith'); second.distribution.desktop = { - credentialEnvironment: 'release-zenith', + credentialEnvironment: 'release', r2Bucket: first.distribution.desktop.r2Bucket, r2Prefix: first.distribution.desktop.r2Prefix, updateUrl: 'https://zenith.example.invalid/desktop/acme/zenith/canary', @@ -244,7 +244,7 @@ describe('parseBrandBuildMatrix', () => { RE_WRONG_CREDENTIAL_ENVIRONMENT, ); - second.distribution.desktop.credentialEnvironment = 'release-zenith'; + second.distribution.desktop.credentialEnvironment = 'release'; second.distribution.mobile.ios.ascAppId = first.distribution.mobile.ios.ascAppId; expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( RE_SHARED_APP_STORE_APP, @@ -327,7 +327,7 @@ describe('release brand matrix workflow', () => { expect(preflight).toContain( 'expected=\'[{"name":"master","type":"branch"},{"name":"v*.*.*","type":"tag"}]\'', ); - expect(preflight).toContain('credentialEnvironment'); + expect(preflight).not.toContain('credentialEnvironment'); expect(preflight).toContain(`GH_TOKEN: ${ACTIONS_EXPRESSION}{{ github.token }}`); expect(preflight).not.toContain('RELEASE_ENVIRONMENT_ADMIN_TOKEN'); expect(workflow).toContain('actions: read'); diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 839197680..ce4fa4e81 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -239,9 +239,6 @@ jobs: fi } check_environment release true - while IFS= read -r environment; do - check_environment "$environment" false - done < <(jq -r '.include[].distribution.desktop.credentialEnvironment' <<<"$BRANDS_JSON") render-inputs: name: Validate immutable render inputs diff --git a/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts b/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts index 1956f538a..2041a6076 100644 --- a/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts +++ b/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts @@ -66,6 +66,20 @@ describe('electronBuilderBrandConfig', () => { expect(serialized.replaceAll('./electron-builder.yml', '')).not.toMatch(/linkcode/i); }); + it('uses the publisher-resolved LinkCode desktop app id verbatim', () => { + const config = electronBuilderBrandConfig( + identity({ + applicationId: 'com.arcboxlabs.linkcode.desktop', + brandId: 'linkcode', + displayName: 'LinkCode', + storageNamespace: 'LinkCode', + urlScheme: 'linkcode', + }), + ); + + expect(config.appId).toBe('com.arcboxlabs.linkcode.desktop'); + }); + it('serializes deterministically', () => { const first = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY)); const second = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY)); diff --git a/apps/mobile/src/build/__tests__/expo-brand.test.ts b/apps/mobile/src/build/__tests__/expo-brand.test.ts index 9cb8e8913..e92192622 100644 --- a/apps/mobile/src/build/__tests__/expo-brand.test.ts +++ b/apps/mobile/src/build/__tests__/expo-brand.test.ts @@ -70,6 +70,28 @@ describe('deriveExpoBrandOverlay', () => { }); }); + it('uses the publisher-resolved LinkCode mobile ids verbatim', () => { + const linkcode = deriveExpoBrandOverlay( + identity('ios', { + applicationId: 'com.arcboxlabs.linkcode.mobile', + brandId: 'linkcode', + displayName: 'LinkCode', + storageNamespace: 'LinkCode', + urlScheme: 'linkcode', + }), + identity('android', { + applicationId: 'com.arcboxlabs.linkcode.mobile', + brandId: 'linkcode', + displayName: 'LinkCode', + storageNamespace: 'LinkCode', + urlScheme: 'linkcode', + }), + ); + + expect(linkcode.iosBundleIdentifier).toBe('com.arcboxlabs.linkcode.mobile'); + expect(linkcode.androidPackage).toBe('com.arcboxlabs.linkcode.mobile'); + }); + it('fails closed on swapped platforms', () => { expect(() => deriveExpoBrandOverlay(identity('android'), identity('android'))).toThrow( /expected an ios identity/, diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index eb1774103..55f15249b 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -128,7 +128,7 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `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. | -| `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | Per-brand R2 account and S3 credentials in the matrix row's exact `release-` Environment. Scope each key pair to only that row's bucket/prefix with object read/write/list; never share one credential Environment between brands. | +| `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | R2 account and S3 credentials in the protected `release` Environment. Scope the key pair to object read/write/list only for the exact bucket/prefix destinations in the reviewed matrix. | | `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | release and config-render workflows | Organization 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; install it on the private repositories selected by `CONFIG_PUBLISHER_REPO` and `CONFIG_SOURCE_REPO` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only 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 792618599..798c44382 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -151,9 +151,9 @@ nonproduction fixture; no other path is accepted. 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 - `credentialEnvironment`, `r2Bucket`, `r2Prefix`, and `updateUrl`. The environment must be exactly - `release-`. Both URL and prefix must end in the same brand/channel path, and prefixes in - one bucket must not overlap. + `credentialEnvironment`, `r2Bucket`, `r2Prefix`, and `updateUrl`. `credentialEnvironment` must be + exactly `release`; no per-brand Environment is part of this contract. Both URL and prefix must end + in the same brand/channel path, and prefixes in one bucket must not overlap. - `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. @@ -179,9 +179,9 @@ upload inputs before any store submission or R2 upload can begin. ### Required Actions configuration and least privilege -Render vars below are read from the protected `release` environment. Signing, upload, store, and -observability inputs are read from the protected `release-` environment selected by the -reviewed matrix. The bot credentials are organization secrets. Trusted workflow steps report +Render vars, signing, upload, store, and observability inputs are read from the single protected +`release` Environment. The reviewed matrix must name that exact Environment for every row. The bot +credentials are organization secrets. Trusted workflow steps report missing bot credentials before checking out selected client code, and the input scripts report missing render, signing, or upload values without receiving those bot credentials: @@ -201,8 +201,8 @@ missing render, signing, or upload values without receiving those bot credential - 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 matching `release-` environment; no client secret exists. -- Desktop observability in `release-`: `SENTRY_DSN_DESKTOP` and + to this repository's `release` Environment; no client secret exists. +- Desktop observability in `release`: `SENTRY_DSN_DESKTOP` and `POSTHOG_PROJECT_TOKEN` plus the `POSTHOG_HOST` var. These are required publishable identifiers, not signing credentials. - Mobile: `EXPO_TOKEN`, `SENTRY_AUTH_TOKEN`, `SENTRY_DSN_MOBILE`, and @@ -212,17 +212,16 @@ missing render, signing, or upload values without receiving those bot credential 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` in each brand's - `release-` environment. 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 +- Desktop upload: `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, and `R2_SECRET_ACCESS_KEY` in `release`. + Scope the key pair to the exact `r2Bucket/r2Prefix` destinations in the reviewed matrix with + object read/write/list only; it must not 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 the committed matrix, repository files, artifacts, or Actions vars. Protect `release` with required reviewers and -only exact `master` plus `v*.*.*` custom deployment policies. Protect every `release-` -environment with required reviewers and only the exact `master` custom deployment policy before -enabling `build`, `sign`, or `upload`. +only exact `master` plus `v*.*.*` custom deployment policies before enabling `build`, `sign`, or +`upload`. No additional release Environment is required by the brand-matrix workflow. The environment preflight reads protection metadata with the built-in `GITHUB_TOKEN` and explicit `actions: read`; this metadata-only token cannot approve or bypass an environment review.