Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/scripts/brand-matrix.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 8 additions & 8 deletions .github/scripts/brand-matrix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(?:\.|\[)/;
Expand Down Expand Up @@ -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',
Expand All @@ -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';
}
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/release-brand-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/build/__tests__/electron-builder-brand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/build/__tests__/expo-brand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down
2 changes: 1 addition & 1 deletion docs/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<brandId>` 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,
Expand Down
27 changes: 13 additions & 14 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<brandId>`. 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/<id>` URL, iOS `appleTeamId`/`ascAppId`, and Android
`track: "internal"`. EAS project IDs and App Store Connect app IDs must be unique across brands.
Expand All @@ -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-<brandId>` 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:

Expand All @@ -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-<brandId>` environment; no client secret exists.
- Desktop observability in `release-<brandId>`: `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
Expand All @@ -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-<brandId>` 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-<brandId>`
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.

Expand Down
Loading