diff --git a/.github/actions/render-release-config/action.yml b/.github/actions/render-release-config/action.yml index e0d7c166e..2edab2dc1 100644 --- a/.github/actions/render-release-config/action.yml +++ b/.github/actions/render-release-config/action.yml @@ -9,14 +9,22 @@ 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-repository: + description: Validated owner/name from vars.CONFIG_PUBLISHER_REPO + required: true publisher-token: - description: Token that can read the config publisher repository (secrets.CONFIG_PUBLISHER_TOKEN) + 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 source-repository + 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 @@ -25,6 +33,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 @@ -45,19 +57,38 @@ runs: shell: bash env: APP: ${{ inputs.app }} - PUBLISHER_REPO: ${{ inputs.publisher-repo }} + 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 }} KEYRINGS_JSON: ${{ inputs.keyrings }} 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 + 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 $PUBLISHER_REPO and $SOURCE_REPO" + 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 @@ -73,7 +104,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" @@ -115,27 +146,112 @@ 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 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 ;; + 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 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 + 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 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" + 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" @@ -144,8 +260,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/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 new file mode 100644 index 000000000..49bb36147 --- /dev/null +++ b/.github/release/brand-matrices/code-561-pilot.json @@ -0,0 +1,205 @@ +{ + "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": "986d9f21403df53bc932f511eb1b5f0bb634d48d", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + }, + "desktop": { + "brandId": "acme", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "936250a3ef922cede3a200b5dc401cc7697ee1db90dc3efd0f873358524f01e3", + "platform": "desktop", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + }, + "ios": { + "brandId": "acme", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "a689a8d95f74d9cb00b5d9850af3ecfd50edb23d2496c71805c9ffe4659d56ae", + "platform": "ios", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", + "telemetryEndpoint": "https://acme.example.invalid/telemetry" + } + }, + "sourceRoot": "examples/acme-zenith" + }, + { + "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": "986d9f21403df53bc932f511eb1b5f0bb634d48d", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", + "telemetryEndpoint": "https://zenith.example.invalid/telemetry" + }, + "desktop": { + "brandId": "zenith", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "99a93cec0ca5381faa15a5def6727736f220b5d7d111e1fce04afda1d321aef2", + "platform": "desktop", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "sourceGitSha": "a1ed4d666721c3aed0d563aaea42fce8b5f945b5", + "telemetryEndpoint": "https://zenith.example.invalid/telemetry" + }, + "ios": { + "brandId": "zenith", + "channel": "canary", + "configRevisionId": "code-561-operational", + "expectedSnapshotSha256": "e1b93b64973e0192ed2e1d8ba9a4cca27ae2bb5521ef6011392c2d86b510b95b", + "platform": "ios", + "publicKeyringsSha256": "1a674a4c47d1ef57e51f7e50e8f044f32cffa0450574f12f23ddeb5cb619d445", + "publisherGitSha": "986d9f21403df53bc932f511eb1b5f0bb634d48d", + "releaseManifestFormatVersion": 1, + "revisionSha256": "e8389e2edc8273c5ec1b029c7101ae18af6e5b55218f0c8fd2dfe640cf695c5b", + "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 new file mode 100644 index 000000000..d79b9582c --- /dev/null +++ b/.github/scripts/brand-matrix.cjs @@ -0,0 +1,343 @@ +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+$/; + +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, ['credentialEnvironment', 'r2Bucket', 'r2Prefix', 'updateUrl'], path); + const updateUrl = httpsUrl(distribution.updateUrl, `${path}.updateUrl`); + const credentialEnvironment = string( + distribution.credentialEnvironment, + `${path}.credentialEnvironment`, + ); + 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); + 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 { + credentialEnvironment, + 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 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', '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`); + 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.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, + }); + } + 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 { createHash } = require('node:crypto'); + 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, + }); + 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('--matrix-file', 'must contain 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)}`, + `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`); + else console.log(outputs.join('\n')); + return plan; +} + +if (require.main === module) runCli(); + +module.exports = { + BUILD_MATRIX_VERSION, + CHECKLIST_KEYS, + PLATFORMS, + buildMatrixPlan, + parseBrandBuildMatrix, + runCli, +}; diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs new file mode 100644 index 000000000..c5a0bdaba --- /dev/null +++ b/.github/scripts/brand-matrix.test.mjs @@ -0,0 +1,511 @@ +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, 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/; +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_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) { + 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'), + }, + sourceRoot: '.', + }; +} + +function matrix(...brands) { + return { brandBuildMatrixVersion: 1, 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(['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); + 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', () => { + 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 = { + credentialEnvironment: 'release', + 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', + }; + expect(() => parseBrandBuildMatrix(matrix(first), { build: true })).not.toThrow(); + 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 and store apps across brands', () => { + const first = brand('acme'); + first.distribution.desktop = { + credentialEnvironment: 'release', + 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 = { + credentialEnvironment: 'release', + 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.credentialEnvironment = 'release-acme'; + expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( + RE_WRONG_CREDENTIAL_ENVIRONMENT, + ); + + 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, + ); + }); + + 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); + + 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 () => { + 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`, + ); + }); +}); + +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('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( + `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.platform }}`); + expect(validation).toContain('"local-static-origin"'); + expect(validation).toContain('providerDeploymentId:null'); + expect(validation).not.toContain('environment: release'); + expect(validation).not.toMatch(RE_SECRETS_EXPRESSION); + expect(validation).not.toContain('brandId:$brandId'); + expect(validation).not.toContain('release-environment-preflight'); + }); + + 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', + ); + const preflight = workflow.slice( + workflow.indexOf(' release-environment-preflight:'), + workflow.indexOf(' render-inputs:'), + ); + + 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/$name"'); + expect(preflight).toContain('deployment-branch-policies?per_page=100'); + expect(preflight).toContain( + 'expected=\'[{"name":"master","type":"branch"},{"name":"v*.*.*","type":"tag"}]\'', + ); + 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'); + 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: release'); + const signingInputs = workflow.slice( + workflow.indexOf(' signing-inputs:'), + workflow.indexOf(' render:'), + ); + expect(signingInputs).toContain('needs: [prepare, release-environment-preflight]'); + 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 () => { + 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('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-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).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:')), + 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( + `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 }}`, + ); + expect(renderJob).toContain( + `source-token: ${ACTIONS_EXPRESSION}{{ steps.source-token.outputs.token }}`, + ); + 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: ${ACTIONS_EXPRESSION}{{ matrix.sourceRoot }}`), + ).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), + '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/scripts/release-inputs.cjs b/.github/scripts/release-inputs.cjs new file mode 100644 index 000000000..83bcff118 --- /dev/null +++ b/.github/scripts/release-inputs.cjs @@ -0,0 +1,112 @@ +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 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'], + ], + 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 === '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 { + 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..7be355065 --- /dev/null +++ b/.github/scripts/release-inputs.test.mjs @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest'; +import inputsModule from './release-inputs.cjs'; + +const { validateReleaseInputs } = inputsModule; +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', () => { + expect(() => validateReleaseInputs({ env: {}, phase: 'render', platform: 'desktop' })).toThrow( + RE_RENDER_MISSING, + ); + }); + + 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, + ); + expect(() => + validateReleaseInputs({ + env: { EXPO_TOKEN: 'non-production-test' }, + phase: 'upload', + platform: 'mobile', + }), + ).not.toThrow(); + expect(() => validateReleaseInputs({ env: {}, phase: 'upload', platform: 'desktop' })).toThrow( + RE_DESKTOP_UPLOAD, + ); + }); + + it('rejects malformed desktop notarization key material', () => { + const env = Object.fromEntries( + [ + 'APPLE_API_KEY_BASE64', + 'APPLE_API_KEY_ID', + 'APPLE_API_ISSUER', + 'APPLE_TEAM_ID', + 'AZURE_CERTIFICATE_PROFILE', + 'AZURE_CLIENT_ID', + 'AZURE_CODE_SIGNING_ACCOUNT', + 'AZURE_PUBLISHER_NAME', + 'AZURE_SIGN_ENDPOINT', + 'AZURE_TENANT_ID', + 'MACOS_CSC_KEY_PASSWORD', + 'MACOS_CSC_LINK', + 'POSTHOG_HOST', + 'POSTHOG_PROJECT_TOKEN', + 'SENTRY_DSN_DESKTOP', + ].map((name) => [name, 'set']), + ); + expect(() => validateReleaseInputs({ env, phase: 'sign', platform: 'desktop' })).toThrow( + RE_INVALID_KEY, + ); + }); + + it('rejects an R2 account value that could change the endpoint authority', () => { + expect(() => + validateReleaseInputs({ + env: { + R2_ACCESS_KEY_ID: 'set', + R2_ACCOUNT_ID: 'example.invalid/path?account=', + R2_SECRET_ACCESS_KEY: 'set', + }, + phase: 'upload', + platform: 'desktop', + }), + ).toThrow(RE_INVALID_ACCOUNT); + }); +}); diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 111b4e0d8..89ba8814d 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: @@ -20,6 +20,31 @@ 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: "" + 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 + required: false + default: "" + update_url: + description: Validated brand-scoped desktop update URL + 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: @@ -36,7 +61,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,29 +82,90 @@ 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 - # 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 }} + if: ${{ inputs.sign && inputs.rendered_artifact == '' }} runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} - environment: release + 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 the selected publisher and source repositories" + 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: ${{ steps.repositories.outputs.publisher-name }} + 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: ${{ steps.repositories.outputs.source-name }} + 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 @@ -91,14 +177,16 @@ jobs: uses: ./.github/actions/render-release-config with: app: desktop - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + 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 }} 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 @@ -112,7 +200,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 @@ -164,11 +252,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 +280,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 +318,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 +360,32 @@ 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 }}' \ + --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" \ + ${{ 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 +402,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..0d881d0c5 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -4,6 +4,38 @@ 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: "" + 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 + required: false + default: "" + submit: + description: Upload to TestFlight and Google Play internal testing + 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: @@ -13,7 +45,7 @@ on: default: false concurrency: - group: build-mobile-production + group: build-mobile-production-${{ inputs.brand_id || 'linkcode' }} cancel-in-progress: false permissions: @@ -31,9 +63,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 +81,76 @@ 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 + 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 the selected publisher and source repositories" + 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: ${{ steps.repositories.outputs.publisher-name }} + 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: ${{ steps.repositories.outputs.source-name }} + 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 @@ -64,8 +162,10 @@ jobs: uses: ./.github/actions/render-release-config with: app: mobile - publisher-repo: ${{ vars.CONFIG_PUBLISHER_REPO }} - publisher-token: ${{ secrets.CONFIG_PUBLISHER_TOKEN }} + 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 }} release-manifest-ios: ${{ vars.CONFIG_RELEASE_MANIFEST_IOS }} @@ -82,9 +182,10 @@ 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 + environment: ${{ inputs.release_environment || 'release' }} strategy: fail-fast: false matrix: @@ -120,6 +221,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 +264,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 +280,30 @@ 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 }}' \ + --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" \ + --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 @@ -191,19 +313,19 @@ 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: 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 +336,7 @@ jobs: - name: Download artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: mobile-${{ matrix.platform }} + name: ${{ inputs.brand_id != '' && format('mobile-{0}-{1}', inputs.brand_id, matrix.platform) || format('mobile-{0}', matrix.platform) }} path: ${{ runner.temp }} - name: Submit artifact diff --git a/.github/workflows/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 diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml new file mode 100644 index 000000000..ce4fa4e81 --- /dev/null +++ b/.github/workflows/release-brand-matrix.yml @@ -0,0 +1,873 @@ +name: Release Brand Matrix + +on: + workflow_dispatch: + inputs: + ref: + 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: Uncommitted matrix JSON for plan validation only + 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-${{ github.sha }} + cancel-in-progress: false + +permissions: + actions: read + contents: read + +jobs: + prepare: + name: Validate matrix + 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 + 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) }}; 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: ${{ github.sha }} + fetch-depth: 0 + + - name: Verify trusted client checkout + env: + CLIENT_REF: ${{ github.sha }} + 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: + 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 }}" + + credential-free-validation: + name: Credential-free ${{ matrix.platform }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + strategy: + fail-fast: false + matrix: + platform: [desktop, ios, android] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: false + - 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 + if: ${{ matrix.platform == 'desktop' }} + 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 + 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 '${{ 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: + CLIENT_REF: ${{ github.sha }} + 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 clientGitSha "$CLIENT_REF" \ + --arg deliveryDescriptorSha256 "$DELIVERY_SHA256" \ + --arg platform "$PLATFORM" \ + --arg 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.platform }} + path: credential-free-evidence + if-no-files-found: error + retention-days: 7 + + release-environment-preflight: + name: Protected release environment preflight + if: ${{ inputs.build }} + needs: prepare + runs-on: ${{ vars.CI_RUNNER_LINUX || 'ubuntu-latest' }} + steps: + - name: Require protected release environment + env: + BRANDS_JSON: ${{ needs.prepare.outputs.brands }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + 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 + + render-inputs: + name: Validate immutable render inputs + if: ${{ inputs.build }} + needs: [prepare, release-environment-preflight] + 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: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + - run: node .github/scripts/release-inputs.cjs --phase render --platform desktop + + 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: ${{ 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 }} + 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: ${{ 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 }} + 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: + - 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 the selected publisher and source repositories" + 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: ${{ steps.repositories.outputs.publisher-name }} + 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: ${{ steps.repositories.outputs.source-name }} + permission-contents: read + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: false + + - 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-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: ${{ matrix.sourceRoot }} + 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-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: ${{ matrix.sourceRoot }} + 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) }} + 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/$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 + 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 '${{ 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" \ + --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' || (!inputs.sign && 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 + permissions: + contents: read + id-token: write + with: + ref: ${{ github.sha }} + sign: ${{ inputs.sign }} + brand_id: ${{ matrix.brandId }} + delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + release_environment: ${{ matrix.distribution.desktop.credentialEnvironment }} + 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: ${{ github.sha }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: false + - 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 '${{ 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 \ + --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' }} + 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: ${{ github.sha }} + - uses: ./.github/actions/setup-eas + with: + cache: false + - 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 '${{ 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" \ + --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 + with: + ref: ${{ github.sha }} + brand_id: ${{ matrix.brandId }} + delivery_descriptor_sha256: ${{ needs.prepare.outputs.delivery_descriptor_sha256 }} + release_environment: ${{ matrix.distribution.desktop.credentialEnvironment }} + 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: ${{ matrix.distribution.desktop.credentialEnvironment }} + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + 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: ${{ github.sha }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: false + - 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 '${{ github.sha }}' \ + --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 \ + --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 '${{ github.sha }}' \ + --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" \ + --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: ${{ matrix.distribution.desktop.credentialEnvironment }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + - uses: ./.github/actions/setup-eas + with: + cache: false + - 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 '${{ github.sha }}' \ + --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" \ + --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: ${{ matrix.distribution.desktop.credentialEnvironment }} + env: + 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: ${{ github.sha }} + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + with: + run_install: false + cache: false + - 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 '${{ github.sha }}' \ + --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 \ + --signed + done + - name: Upload only this brand prefix + env: + AWS_REGION: auto + AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED + AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED + run: | + aws s3 sync "artifacts/${{ matrix.brandId }}/" \ + "s3://${{ matrix.distribution.desktop.r2Bucket }}/${{ matrix.distribution.desktop.r2Prefix }}/" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --no-progress diff --git a/apps/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/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..e92192622 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'; @@ -68,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/, @@ -178,3 +202,30 @@ describe('applyBrandExpoConfig', () => { expect(applyBrandExpoConfig(BASE, ACME)).toStrictEqual(branded); }); }); + +describe('brand release config', () => { + const release = parseExpoBrandReleaseConfig({ + android: { track: 'internal' }, + brandId: 'acme', + channel: 'stable', + easProjectId: '11111111-1111-4111-8111-111111111111', + ios: { appleTeamId: 'ABC1234567', ascAppId: '1234567890' }, + mobileReleaseFormatVersion: 1, + updatesUrl: 'https://u.expo.dev/11111111-1111-4111-8111-111111111111', + }); + + it('injects only the brand-scoped EAS/update delivery binding', () => { + const branded = applyBrandReleaseConfig(applyBrandExpoConfig(BASE, ACME), ACME, release); + expect(branded.extra).toStrictEqual({ eas: { projectId: release.easProjectId } }); + expect(branded.updates?.url).toBe(release.updatesUrl); + expect(branded.ios?.appleTeamId).toBe(release.ios.appleTeamId); + }); + + it('rejects cross-brand bindings, unknown fields, and non-internal delivery', () => { + expect(() => applyBrandReleaseConfig(BASE, ZENITH, release)).toThrow(/does not match/); + expect(() => parseExpoBrandReleaseConfig({ ...release, executable: 'payload' })).toThrow(/exactly/); + expect(() => + parseExpoBrandReleaseConfig({ ...release, android: { track: 'production' } }), + ).toThrow(/must be internal/); + }); +}); diff --git a/apps/mobile/src/build/expo-brand.ts b/apps/mobile/src/build/expo-brand.ts index df192d0c6..72f2575df 100644 --- a/apps/mobile/src/build/expo-brand.ts +++ b/apps/mobile/src/build/expo-brand.ts @@ -16,6 +16,16 @@ export interface ExpoBrandOverlay { readonly urlScheme: string; } +export interface ExpoBrandReleaseConfig { + readonly android: { readonly track: 'internal' }; + readonly brandId: string; + readonly channel: string; + readonly easProjectId: string; + readonly ios: { readonly appleTeamId: string; readonly ascAppId: string }; + readonly mobileReleaseFormatVersion: 1; + readonly updatesUrl: string; +} + /** Staged brand icon, relative to apps/mobile (where app.config.ts resolves asset paths). */ export const MOBILE_BRAND_ICON_PATH = './generated/brand-assets/icon.png'; @@ -105,6 +115,77 @@ export function parseExpoBrandOverlay(value: unknown): ExpoBrandOverlay { return record as unknown as ExpoBrandOverlay; } +const RELEASE_KEYS = [ + 'android', + 'brandId', + 'channel', + 'easProjectId', + 'ios', + 'mobileReleaseFormatVersion', + 'updatesUrl', +] as const; +const RE_EAS_PROJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const RE_APPLE_TEAM_ID = /^[A-Z0-9]{10}$/; +const RE_ASC_APP_ID = /^\d+$/; + +export function parseExpoBrandReleaseConfig(value: unknown): ExpoBrandReleaseConfig { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + fail('Expo brand release config must be a JSON object'); + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== RELEASE_KEYS.length || keys.some((key, index) => key !== RELEASE_KEYS[index])) { + fail(`Expo brand release config must contain exactly: ${RELEASE_KEYS.join(', ')}`); + } + if (record.mobileReleaseFormatVersion !== 1) fail('Expo brand release config version must be 1'); + for (const field of ['brandId', 'channel', 'easProjectId', 'updatesUrl']) { + if (typeof record[field] !== 'string' || record[field] === '') fail(`Expo brand release ${field} is required`); + } + if (!RE_EAS_PROJECT_ID.test(record.easProjectId as string)) fail('Expo brand release easProjectId is invalid'); + if (record.updatesUrl !== `https://u.expo.dev/${record.easProjectId as string}`) { + fail('Expo brand release updatesUrl must match easProjectId'); + } + if (typeof record.ios !== 'object' || record.ios === null || Array.isArray(record.ios)) { + fail('Expo brand release ios is invalid'); + } + const ios = record.ios as Record; + if ( + Object.keys(ios).sort().join(',') !== 'appleTeamId,ascAppId' || + typeof ios.appleTeamId !== 'string' || + !RE_APPLE_TEAM_ID.test(ios.appleTeamId) || + typeof ios.ascAppId !== 'string' || + !RE_ASC_APP_ID.test(ios.ascAppId) + ) { + fail('Expo brand release ios identifiers are invalid'); + } + if ( + typeof record.android !== 'object' || + record.android === null || + Array.isArray(record.android) || + Object.keys(record.android).join(',') !== 'track' || + (record.android as { track?: unknown }).track !== 'internal' + ) { + fail('Expo brand release Android track must be internal'); + } + return record as unknown as ExpoBrandReleaseConfig; +} + +export function applyBrandReleaseConfig( + config: ExpoBrandableConfig, + overlay: ExpoBrandOverlay, + release: ExpoBrandReleaseConfig, +): ExpoBrandableConfig { + if (release.brandId !== overlay.brandId || release.channel !== overlay.channel) { + fail(`Expo brand release target ${release.brandId}/${release.channel} does not match ${overlay.brandId}/${overlay.channel}`); + } + return { + ...config, + extra: { ...config.extra, eas: { projectId: release.easProjectId } }, + ios: { ...config.ios, appleTeamId: release.ios.appleTeamId }, + updates: { ...config.updates, url: release.updatesUrl }, + }; +} + /** The default product name as it appears in user-facing template strings of the base config * (permission prompts). Only exact-case occurrences are rebranded; lowercase protocol/service * identifiers (`_linkcode._tcp`) are shared-core runtime contracts and stay untouched. */ diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 96760064d..55f15249b 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -109,7 +109,8 @@ 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. | +| `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 @@ -123,11 +124,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. | -| `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. | +| `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, 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 16d1fc0ca..798c44382 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 @@ -86,17 +89,142 @@ 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 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 +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 `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) — `owner/name` of the private config publisher repository. -- `CONFIG_PUBLISHER_TOKEN` (secret) — read token for that repository. +- `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. 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`) 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 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`, `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 + 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 + `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. + +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 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 +upload inputs before any store submission or R2 upload can begin. + +### Required Actions configuration and least privilege + +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: + +- `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 + 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. +- 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 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 + 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` 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 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. + ## Packaging inputs (staging & version pins) - **Per-arch single-importer staging (CODE-107).** electron-builder never packs `apps/desktop` in place; `apps/desktop/scripts/package-app.mts` runs `pnpm --prod deploy --legacy --cpu=` into one self-contained dir per target architecture **outside** the workspace, then invokes electron-builder once per dir. This is load-bearing twice: selecting one CPU keeps napi-rs optional bindings target-pure, while `appDir === projectDir === workspaceRoot` makes `@electron/rebuild` find better-sqlite3 on Windows and keeps the module collector on one importer. Separate macOS/Windows invocations target the same updater manifest, so the script merges their `files` arrays afterward while retaining x64 as the legacy `path`/`sha512`; Linux already names updater manifests per architecture. CI runs `node scripts/package-app.mts --publish never …` in place of a bare `electron-builder`. diff --git a/packages/foundation/common/src/node/__tests__/release-artifact.test.ts b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts new file mode 100644 index 000000000..67950baa7 --- /dev/null +++ b/packages/foundation/common/src/node/__tests__/release-artifact.test.ts @@ -0,0 +1,367 @@ +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); +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 () => { + 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, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, + 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.deliveryDescriptorSha256).toBe(expectedDeliveryDescriptorSha256); + 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, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, + 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), + deliveryDescriptorSha256: 'e'.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, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, + 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, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, + 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: 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, + brandIdentity: identity, + brandManifestBytes: new TextEncoder().encode('brands: [acme]'), + brandId: 'zenith', + bundle, + clientGitSha, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, + 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, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, + 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, + deliveryDescriptorBytes, + expectedDeliveryDescriptorSha256, + platform: bundle.platform, + provenance, + releaseManifest, + releaseManifestBytes: new TextEncoder().encode('{}'), + signed: true, + }), + ).rejects.toThrow('bytes do not match'); + }); +}); diff --git a/packages/foundation/common/src/node/index.ts b/packages/foundation/common/src/node/index.ts index 6c5526f0d..fe36b43c9 100644 --- a/packages/foundation/common/src/node/index.ts +++ b/packages/foundation/common/src/node/index.ts @@ -15,6 +15,7 @@ export * from './brand-assets'; export * from './config-brand-render'; export * from './config-build-render'; export { executableSearchLocations } from './executable-locations'; +export * from './release-artifact'; export * from './windows-path'; /** Parse a JSON file, or `null` when it is missing, unreadable, or malformed. */ diff --git a/packages/foundation/common/src/node/release-artifact-cli.mts b/packages/foundation/common/src/node/release-artifact-cli.mts new file mode 100644 index 000000000..e1285ef75 --- /dev/null +++ b/packages/foundation/common/src/node/release-artifact-cli.mts @@ -0,0 +1,184 @@ +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 + --delivery-descriptor --release-manifest --compliance + --expected-delivery-sha256 --client-git-sha --out [--signed] + release-artifact --artifact-root --verify + --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}`); +} + +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' }, + 'delivery-descriptor': { type: 'string' }, + 'expected-brand': { type: 'string' }, + 'expected-delivery-sha256': { 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'), + deliveryDescriptorBytes: await readFile(required('delivery-descriptor')), + expectedDeliveryDescriptorSha256: required('expected-delivery-sha256'), + 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), + deliveryDescriptorBytes: await readFile(required('delivery-descriptor')), + expectedDeliveryDescriptorSha256: required('expected-delivery-sha256'), + releaseManifest: releaseManifest(releaseInput.value), + releaseManifestBytes: releaseInput.bytes, + signed: values.signed, + }); + await writeReleaseArtifactProvenance(required('out'), provenance, artifactRoot); + process.stdout.write( + `wrote provenance for ${provenance.brandId}/${provenance.platform}/${provenance.channel}\n`, + ); +} + +main().catch((error: unknown) => { + process.stderr.write(`${extractErrorMessage(error)}\n`); + process.exitCode = 1; +}); diff --git a/packages/foundation/common/src/node/release-artifact.ts b/packages/foundation/common/src/node/release-artifact.ts new file mode 100644 index 000000000..025c13145 --- /dev/null +++ b/packages/foundation/common/src/node/release-artifact.ts @@ -0,0 +1,394 @@ +/// +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 deliveryDescriptorSha256: 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 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], + ['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 deliveryDescriptorBytes: Uint8Array; + readonly expectedDeliveryDescriptorSha256: string; + 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 deliverySha256 = deliveryDescriptorSha256( + input.deliveryDescriptorBytes, + input.expectedDeliveryDescriptorSha256, + ); + 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, + deliveryDescriptorSha256: deliverySha256, + 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', + 'deliveryDescriptorSha256', + '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.deliveryDescriptorSha256 !== 'string' || + !RE_SHA256.test(provenance.deliveryDescriptorSha256) || + 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, + deliveryDescriptorSha256: provenance.deliveryDescriptorSha256, + 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 deliveryDescriptorBytes: Uint8Array; + readonly expectedDeliveryDescriptorSha256: 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))), + ); + const deliverySha256 = deliveryDescriptorSha256( + input.deliveryDescriptorBytes, + input.expectedDeliveryDescriptorSha256, + ); + 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.deliveryDescriptorSha256 !== deliverySha256 || + provenance.publisherGitSha !== input.releaseManifest.publisherGitSha || + provenance.releaseManifestSha256 !== sha256(input.releaseManifestBytes) || + provenance.sourceGitSha !== input.bundle.provenance.sourceGitSha + ) { + throw new Error('release provenance does not match the expected immutable release target'); + } + const [first] = provenance.artifacts; + await Promise.all( + provenance.artifacts.map(async (expected) => { + if ( + expected.brandManifestSha256 !== brandManifestSha256 || + expected.brandManifestSha256 !== first.brandManifestSha256 || + expected.configRevisionId !== input.bundle.provenance.configRevisionId || + expected.configRevisionId !== first.configRevisionId || + expected.defaultsSha256 !== defaultsSha256 || + expected.defaultsSha256 !== first.defaultsSha256 + ) { + throw new Error('release provenance artifacts do not share immutable trace bindings'); + } + const actual = await artifactFile(input.artifactRoot, expected.path); + if (actual.sha256 !== expected.sha256 || actual.sizeBytes !== expected.sizeBytes) { + throw new Error(`artifact bytes do not match release provenance: ${expected.path}`); + } + }), + ); + return provenance; +} + +export async function writeReleaseArtifactProvenance( + path: string, + provenance: ReleaseArtifactProvenance, + artifactRoot: string, +): Promise { + const absoluteRoot = await realpath(artifactRoot); + const absolutePath = resolve(artifactRoot, path); + const relativePath = relative(absoluteRoot, absolutePath); + if (relativePath === '' || relativePath.startsWith('..') || relativePath.includes('\\')) { + throw new TypeError(`provenance path escapes its isolated root: ${path}`); + } + const canonicalParent = await realpath(dirname(absolutePath)); + if (relative(absoluteRoot, canonicalParent).startsWith('..')) { + throw new TypeError(`provenance path resolves outside its isolated root: ${path}`); + } + const output = `${canonicalizeJson(jsonValue(provenance))}\n`; + await writeFile(absolutePath, output, { encoding: 'utf8', flag: 'wx' }); +} + +function jsonValue(value: unknown): JsonValue { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + typeof value === 'number' + ) { + return value; + } + if (Array.isArray(value)) return value.map(jsonValue); + if (typeof value !== 'object') { + throw new TypeError('release provenance must contain only JSON values'); + } + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, jsonValue(entry)])); +} + +export function parseReleaseArtifactInputs( + bundle: unknown, + identity: unknown, +): { + readonly bundle: ConfigBuildBundle; + readonly identity: BrandIdentityArtifact; +} { + return { bundle: parseConfigBuildBundle(bundle), identity: parseBrandIdentityArtifact(identity) }; +} diff --git a/packages/foundation/common/src/node/release-compliance.ts b/packages/foundation/common/src/node/release-compliance.ts new file mode 100644 index 000000000..21c893b52 --- /dev/null +++ b/packages/foundation/common/src/node/release-compliance.ts @@ -0,0 +1,117 @@ +import type { ConfigBuildBundle } from '../config'; +import { configBuildBundleSnapshot } from '../config'; + +export interface StoreComplianceDeclaration { + readonly checklist: Readonly>; + readonly disclosedFeatures: readonly string[]; +} + +const RE_EXECUTABLE_STRING = + /^\s*(?:#!|javascript:|data:\s*(?:application|text)\/(?:ecmascript|javascript)|data:\s*application\/wasm)| Object.keys(override.set)), + ...Object.keys(snapshot.rollouts), + ]; +} + +function configurationKeyTokens(key: string): readonly string[] { + return key + .replaceAll(RE_CAMEL_CASE_BOUNDARY, '$1.$2') + .split(RE_KEY_SEGMENT_SPLIT) + .map((token) => token.toLowerCase()); +} + +function assertSafeConfigurationValue( + value: unknown, + path: string, + disclosedFeatures: ReadonlySet, +): void { + if (typeof value === 'string' && RE_EXECUTABLE_STRING.test(value)) { + throw new TypeError(`${path} looks like executable code or an executable-code URL`); + } + if (Array.isArray(value)) { + for (const [index, entry] of value.entries()) { + assertSafeConfigurationValue(entry, `${path}[${index}]`, disclosedFeatures); + } + return; + } + if (typeof value !== 'object' || value === null) return; + for (const [key, entry] of Object.entries(value)) { + const tokens = configurationKeyTokens(key); + if (tokens.some((token) => EXECUTABLE_KEY_TOKENS.has(token))) { + throw new TypeError(`${path}.${key} declares an executable-code surface`); + } + if (tokens.some((token) => token.startsWith('review')) && !disclosedFeatures.has(key)) { + throw new TypeError( + `review configuration key ${path}.${key} is not a disclosed feature/module`, + ); + } + assertSafeConfigurationValue(entry, `${path}.${key}`, disclosedFeatures); + } +} + +export function assertStoreCompliance( + bundle: ConfigBuildBundle, + declaration: StoreComplianceDeclaration, +): void { + const checklistKeys = Object.keys(declaration.checklist).sort(); + if ( + checklistKeys.length !== STORE_CHECKLIST_KEYS.length || + checklistKeys.some((key, index) => key !== STORE_CHECKLIST_KEYS[index]) + ) { + throw new TypeError( + `compliance checklist must contain exactly: ${STORE_CHECKLIST_KEYS.join(', ')}`, + ); + } + for (const key of STORE_CHECKLIST_KEYS) { + if (!declaration.checklist[key]) { + throw new TypeError(`compliance checklist ${key} must be true`); + } + } + const keys = [...new Set(configurationKeys(bundle))].sort(); + const configurableFeatures = keys.filter( + (key) => key.startsWith('feature.') || key.startsWith('modules.'), + ); + if (JSON.stringify(configurableFeatures) !== JSON.stringify(declaration.disclosedFeatures)) { + throw new TypeError( + `disclosedFeatures must exactly match configurable feature/module keys: ${configurableFeatures.join(', ')}`, + ); + } + for (const key of keys) { + if (configurationKeyTokens(key).some((token) => EXECUTABLE_KEY_TOKENS.has(token))) { + throw new TypeError(`configuration key ${key} declares an executable-code surface`); + } + } + assertSafeConfigurationValue( + configBuildBundleSnapshot(bundle), + 'snapshot', + new Set(configurableFeatures), + ); +}