From 1cb60809c6dac98a2437412378931fbd050ded8a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 04:04:51 +0000 Subject: [PATCH] fix(release): unify target identity contract --- .github/scripts/brand-matrix.cjs | 4 +-- .github/scripts/brand-matrix.test.mjs | 16 +++++------ .github/workflows/release-brand-matrix.yml | 3 --- .../__tests__/electron-builder-brand.test.ts | 14 ++++++++++ .../src/build/__tests__/expo-brand.test.ts | 22 +++++++++++++++ docs/ENVIRONMENT.md | 2 +- docs/RELEASE.md | 27 +++++++++---------- 7 files changed, 60 insertions(+), 28 deletions(-) diff --git a/.github/scripts/brand-matrix.cjs b/.github/scripts/brand-matrix.cjs index 544df61c0..d79b9582c 100644 --- a/.github/scripts/brand-matrix.cjs +++ b/.github/scripts/brand-matrix.cjs @@ -131,8 +131,8 @@ function desktopDistribution(value, path, brandId, channel) { distribution.credentialEnvironment, `${path}.credentialEnvironment`, ); - if (credentialEnvironment !== `release-${brandId}`) { - fail(`${path}.credentialEnvironment`, `must equal release-${brandId}`); + if (credentialEnvironment !== 'release') { + fail(`${path}.credentialEnvironment`, 'must equal release'); } const r2Bucket = string(distribution.r2Bucket, `${path}.r2Bucket`, RE_BUCKET); const r2Prefix = string(distribution.r2Prefix, `${path}.r2Prefix`, RE_R2_PREFIX); diff --git a/.github/scripts/brand-matrix.test.mjs b/.github/scripts/brand-matrix.test.mjs index cf03e20ea..c5a0bdaba 100644 --- a/.github/scripts/brand-matrix.test.mjs +++ b/.github/scripts/brand-matrix.test.mjs @@ -17,7 +17,7 @@ const RE_MISSING_BRAND_SEGMENT = /must include the brand id/; const RE_UNKNOWN_FIELD = /must contain exactly/; const RE_DIVERGENT_SOURCE = /all platforms must share sourceGitSha/; const RE_SHARED_DESTINATION = /R2 prefixes in one bucket must not overlap/; -const RE_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal release-/; +const RE_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal release/; const RE_SHARED_APP_STORE_APP = /ios\.ascAppId: must be unique/; const RE_INVALID_SOURCE_ROOT = /sourceRoot: must be/; const RE_SECRETS_EXPRESSION = /secrets(?:\.|\[)/; @@ -177,7 +177,7 @@ describe('parseBrandBuildMatrix', () => { const first = brand('acme'); first.distribution.desktop = { - credentialEnvironment: 'release-acme', + credentialEnvironment: 'release', r2Bucket: 'release-acme', r2Prefix: 'desktop/acme/canary', updateUrl: 'https://acme.example.invalid/desktop/acme/canary', @@ -188,9 +188,9 @@ describe('parseBrandBuildMatrix', () => { ios: { appleTeamId: 'ABC1234567', ascAppId: '1234567890' }, updatesUrl: 'https://u.expo.dev/11111111-1111-4111-8111-111111111111', }; + expect(() => parseBrandBuildMatrix(matrix(first), { build: true })).not.toThrow(); const second = structuredClone(first); second.brandId = 'zenith'; - second.distribution.desktop.credentialEnvironment = 'release-zenith'; for (const platform of ['desktop', 'ios', 'android']) { second.releaseManifests[platform].brandId = 'zenith'; } @@ -199,10 +199,10 @@ describe('parseBrandBuildMatrix', () => { ); }); - it('rejects shared R2 destinations, credentials, and store apps across brands', () => { + it('rejects shared R2 destinations and store apps across brands', () => { const first = brand('acme'); first.distribution.desktop = { - credentialEnvironment: 'release-acme', + credentialEnvironment: 'release', r2Bucket: 'release-brands', r2Prefix: 'desktop/acme/zenith/canary', updateUrl: 'https://acme.example.invalid/desktop/acme/zenith/canary', @@ -215,7 +215,7 @@ describe('parseBrandBuildMatrix', () => { }; const second = brand('zenith'); second.distribution.desktop = { - credentialEnvironment: 'release-zenith', + credentialEnvironment: 'release', r2Bucket: first.distribution.desktop.r2Bucket, r2Prefix: first.distribution.desktop.r2Prefix, updateUrl: 'https://zenith.example.invalid/desktop/acme/zenith/canary', @@ -244,7 +244,7 @@ describe('parseBrandBuildMatrix', () => { RE_WRONG_CREDENTIAL_ENVIRONMENT, ); - second.distribution.desktop.credentialEnvironment = 'release-zenith'; + second.distribution.desktop.credentialEnvironment = 'release'; second.distribution.mobile.ios.ascAppId = first.distribution.mobile.ios.ascAppId; expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow( RE_SHARED_APP_STORE_APP, @@ -327,7 +327,7 @@ describe('release brand matrix workflow', () => { expect(preflight).toContain( 'expected=\'[{"name":"master","type":"branch"},{"name":"v*.*.*","type":"tag"}]\'', ); - expect(preflight).toContain('credentialEnvironment'); + expect(preflight).not.toContain('credentialEnvironment'); expect(preflight).toContain(`GH_TOKEN: ${ACTIONS_EXPRESSION}{{ github.token }}`); expect(preflight).not.toContain('RELEASE_ENVIRONMENT_ADMIN_TOKEN'); expect(workflow).toContain('actions: read'); diff --git a/.github/workflows/release-brand-matrix.yml b/.github/workflows/release-brand-matrix.yml index 839197680..ce4fa4e81 100644 --- a/.github/workflows/release-brand-matrix.yml +++ b/.github/workflows/release-brand-matrix.yml @@ -239,9 +239,6 @@ jobs: fi } check_environment release true - while IFS= read -r environment; do - check_environment "$environment" false - done < <(jq -r '.include[].distribution.desktop.credentialEnvironment' <<<"$BRANDS_JSON") render-inputs: name: Validate immutable render inputs diff --git a/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts b/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts index 1956f538a..2041a6076 100644 --- a/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts +++ b/apps/desktop/src/build/__tests__/electron-builder-brand.test.ts @@ -66,6 +66,20 @@ describe('electronBuilderBrandConfig', () => { expect(serialized.replaceAll('./electron-builder.yml', '')).not.toMatch(/linkcode/i); }); + it('uses the publisher-resolved LinkCode desktop app id verbatim', () => { + const config = electronBuilderBrandConfig( + identity({ + applicationId: 'com.arcboxlabs.linkcode.desktop', + brandId: 'linkcode', + displayName: 'LinkCode', + storageNamespace: 'LinkCode', + urlScheme: 'linkcode', + }), + ); + + expect(config.appId).toBe('com.arcboxlabs.linkcode.desktop'); + }); + it('serializes deterministically', () => { const first = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY)); const second = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY)); diff --git a/apps/mobile/src/build/__tests__/expo-brand.test.ts b/apps/mobile/src/build/__tests__/expo-brand.test.ts index 9cb8e8913..e92192622 100644 --- a/apps/mobile/src/build/__tests__/expo-brand.test.ts +++ b/apps/mobile/src/build/__tests__/expo-brand.test.ts @@ -70,6 +70,28 @@ describe('deriveExpoBrandOverlay', () => { }); }); + it('uses the publisher-resolved LinkCode mobile ids verbatim', () => { + const linkcode = deriveExpoBrandOverlay( + identity('ios', { + applicationId: 'com.arcboxlabs.linkcode.mobile', + brandId: 'linkcode', + displayName: 'LinkCode', + storageNamespace: 'LinkCode', + urlScheme: 'linkcode', + }), + identity('android', { + applicationId: 'com.arcboxlabs.linkcode.mobile', + brandId: 'linkcode', + displayName: 'LinkCode', + storageNamespace: 'LinkCode', + urlScheme: 'linkcode', + }), + ); + + expect(linkcode.iosBundleIdentifier).toBe('com.arcboxlabs.linkcode.mobile'); + expect(linkcode.androidPackage).toBe('com.arcboxlabs.linkcode.mobile'); + }); + it('fails closed on swapped platforms', () => { expect(() => deriveExpoBrandOverlay(identity('android'), identity('android'))).toThrow( /expected an ios identity/, diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index eb1774103..55f15249b 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -128,7 +128,7 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz | `AZURE_PUBLISHER_NAME`, `AZURE_SIGN_ENDPOINT`, `AZURE_CODE_SIGNING_ACCOUNT`, `AZURE_CERTIFICATE_PROFILE` | `build-desktop.yml` | Windows Trusted Signing identifiers (not credentials, but kept as secrets so the public repo doesn't advertise the signing infrastructure). `AZURE_PUBLISHER_NAME` must match the certificate subject CN exactly. | | `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` | `build-desktop.yml` | `azure/login` **inputs** for OIDC federation. No `AZURE_*` credential env exists during packaging on purpose, so `DefaultAzureCredential` falls through to the Azure CLI entry. | | `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-desktop.yml` | Cloudflare R2 credentials for publishing the electron-updater feed. `AWS_REQUEST_CHECKSUM_CALCULATION`/`AWS_RESPONSE_CHECKSUM_VALIDATION` are pinned to `WHEN_REQUIRED` because R2 doesn't implement the checksums recent aws-cli sends. | -| `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | Per-brand R2 account and S3 credentials in the matrix row's exact `release-` Environment. Scope each key pair to only that row's bucket/prefix with object read/write/list; never share one credential Environment between brands. | +| `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | `release-brand-matrix.yml` | R2 account and S3 credentials in the protected `release` Environment. Scope the key pair to object read/write/list only for the exact bucket/prefix destinations in the reviewed matrix. | | `BOT_APP_ID`, `BOT_APP_PRIVATE_KEY` | release and config-render workflows | Organization GitHub App credentials. The App needs Contents, Issues, and Pull requests read/write on this repo so release-please can maintain PRs, draft Releases, and tags; install it on the private repositories selected by `CONFIG_PUBLISHER_REPO` and `CONFIG_SOURCE_REPO` so config rendering can mint separate short-lived tokens restricted to Contents read on each repository. Package-manager bumps additionally require installations on `arcboxlabs/homebrew-tap` and `arcboxlabs/winget-pkgs` with contents + pull-requests write. Missing credentials fail release automation before any tag is created; only package-manager bumps remain an optional self-skip. | Mobile certificates, provisioning profiles, the Android keystore, the App Store Connect API key, diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 792618599..798c44382 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -151,9 +151,9 @@ nonproduction fixture; no other path is accepted. checklist with all five keys set to `true`: `configurableFeaturesDisclosed`, `dataPracticesReviewed`, `noExecutableCode`, `permissionsReviewed`, and `storeMetadataReviewed`. - `distribution.desktop` may be `null` only for plan validation. Every build requires an object containing - `credentialEnvironment`, `r2Bucket`, `r2Prefix`, and `updateUrl`. The environment must be exactly - `release-`. Both URL and prefix must end in the same brand/channel path, and prefixes in - one bucket must not overlap. + `credentialEnvironment`, `r2Bucket`, `r2Prefix`, and `updateUrl`. `credentialEnvironment` must be + exactly `release`; no per-brand Environment is part of this contract. Both URL and prefix must end + in the same brand/channel path, and prefixes in one bucket must not overlap. - `distribution.mobile` may be `null` only for plan validation. Every build requires `easProjectId`, its exact `https://u.expo.dev/` URL, iOS `appleTeamId`/`ascAppId`, and Android `track: "internal"`. EAS project IDs and App Store Connect app IDs must be unique across brands. @@ -179,9 +179,9 @@ upload inputs before any store submission or R2 upload can begin. ### Required Actions configuration and least privilege -Render vars below are read from the protected `release` environment. Signing, upload, store, and -observability inputs are read from the protected `release-` environment selected by the -reviewed matrix. The bot credentials are organization secrets. Trusted workflow steps report +Render vars, signing, upload, store, and observability inputs are read from the single protected +`release` Environment. The reviewed matrix must name that exact Environment for every row. The bot +credentials are organization secrets. Trusted workflow steps report missing bot credentials before checking out selected client code, and the input scripts report missing render, signing, or upload values without receiving those bot credentials: @@ -201,8 +201,8 @@ missing render, signing, or upload values without receiving those bot credential - Windows Desktop: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_PUBLISHER_NAME`, `AZURE_SIGN_ENDPOINT`, `AZURE_CODE_SIGNING_ACCOUNT`, and `AZURE_CERTIFICATE_PROFILE`. The Azure app has only the Trusted Signing certificate-profile signer role and an OIDC subject restricted - to this repository's matching `release-` environment; no client secret exists. -- Desktop observability in `release-`: `SENTRY_DSN_DESKTOP` and + to this repository's `release` Environment; no client secret exists. +- Desktop observability in `release`: `SENTRY_DSN_DESKTOP` and `POSTHOG_PROJECT_TOKEN` plus the `POSTHOG_HOST` var. These are required publishable identifiers, not signing credentials. - Mobile: `EXPO_TOKEN`, `SENTRY_AUTH_TOKEN`, `SENTRY_DSN_MOBILE`, and @@ -212,17 +212,16 @@ missing render, signing, or upload values without receiving those bot credential Native certificates, provisioning profiles, Android keystores, App Store Connect keys, and Google Play service accounts stay EAS-managed and project-scoped. Submissions stop at TestFlight and the Play internal track; this workflow never submits to App Review or promotes a Play release. -- Desktop upload: `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, and `R2_SECRET_ACCESS_KEY` in each brand's - `release-` environment. Each key pair is scoped to that brand's one `r2Bucket/r2Prefix` - with object read/write/list only; it must not access another brand prefix or permit +- Desktop upload: `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, and `R2_SECRET_ACCESS_KEY` in `release`. + Scope the key pair to the exact `r2Bucket/r2Prefix` destinations in the reviewed matrix with + object read/write/list only; it must not permit bucket/account administration. `R2_ACCOUNT_ID` is exactly the lowercase 32-hex Cloudflare account ID; URL-like or otherwise malformed values fail before AWS CLI runs. Do not store private signing material, access tokens, or service-account JSON in the committed matrix, repository files, artifacts, or Actions vars. Protect `release` with required reviewers and -only exact `master` plus `v*.*.*` custom deployment policies. Protect every `release-` -environment with required reviewers and only the exact `master` custom deployment policy before -enabling `build`, `sign`, or `upload`. +only exact `master` plus `v*.*.*` custom deployment policies before enabling `build`, `sign`, or +`upload`. No additional release Environment is required by the brand-matrix workflow. The environment preflight reads protection metadata with the built-in `GITHUB_TOKEN` and explicit `actions: read`; this metadata-only token cannot approve or bypass an environment review.