Skip to content

Commit b915e88

Browse files
committed
fix(release): unify target identity contract
1 parent a26a78c commit b915e88

7 files changed

Lines changed: 60 additions & 28 deletions

File tree

.github/scripts/brand-matrix.cjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ function desktopDistribution(value, path, brandId, channel) {
131131
distribution.credentialEnvironment,
132132
`${path}.credentialEnvironment`,
133133
);
134-
if (credentialEnvironment !== `release-${brandId}`) {
135-
fail(`${path}.credentialEnvironment`, `must equal release-${brandId}`);
134+
if (credentialEnvironment !== 'release') {
135+
fail(`${path}.credentialEnvironment`, 'must equal release');
136136
}
137137
const r2Bucket = string(distribution.r2Bucket, `${path}.r2Bucket`, RE_BUCKET);
138138
const r2Prefix = string(distribution.r2Prefix, `${path}.r2Prefix`, RE_R2_PREFIX);

.github/scripts/brand-matrix.test.mjs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const RE_MISSING_BRAND_SEGMENT = /must include the brand id/;
1717
const RE_UNKNOWN_FIELD = /must contain exactly/;
1818
const RE_DIVERGENT_SOURCE = /all platforms must share sourceGitSha/;
1919
const RE_SHARED_DESTINATION = /R2 prefixes in one bucket must not overlap/;
20-
const RE_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal release-/;
20+
const RE_WRONG_CREDENTIAL_ENVIRONMENT = /credentialEnvironment: must equal release/;
2121
const RE_SHARED_APP_STORE_APP = /ios\.ascAppId: must be unique/;
2222
const RE_INVALID_SOURCE_ROOT = /sourceRoot: must be/;
2323
const RE_SECRETS_EXPRESSION = /secrets(?:\.|\[)/;
@@ -177,7 +177,7 @@ describe('parseBrandBuildMatrix', () => {
177177

178178
const first = brand('acme');
179179
first.distribution.desktop = {
180-
credentialEnvironment: 'release-acme',
180+
credentialEnvironment: 'release',
181181
r2Bucket: 'release-acme',
182182
r2Prefix: 'desktop/acme/canary',
183183
updateUrl: 'https://acme.example.invalid/desktop/acme/canary',
@@ -188,9 +188,9 @@ describe('parseBrandBuildMatrix', () => {
188188
ios: { appleTeamId: 'ABC1234567', ascAppId: '1234567890' },
189189
updatesUrl: 'https://u.expo.dev/11111111-1111-4111-8111-111111111111',
190190
};
191+
expect(() => parseBrandBuildMatrix(matrix(first), { build: true })).not.toThrow();
191192
const second = structuredClone(first);
192193
second.brandId = 'zenith';
193-
second.distribution.desktop.credentialEnvironment = 'release-zenith';
194194
for (const platform of ['desktop', 'ios', 'android']) {
195195
second.releaseManifests[platform].brandId = 'zenith';
196196
}
@@ -199,10 +199,10 @@ describe('parseBrandBuildMatrix', () => {
199199
);
200200
});
201201

202-
it('rejects shared R2 destinations, credentials, and store apps across brands', () => {
202+
it('rejects shared R2 destinations and store apps across brands', () => {
203203
const first = brand('acme');
204204
first.distribution.desktop = {
205-
credentialEnvironment: 'release-acme',
205+
credentialEnvironment: 'release',
206206
r2Bucket: 'release-brands',
207207
r2Prefix: 'desktop/acme/zenith/canary',
208208
updateUrl: 'https://acme.example.invalid/desktop/acme/zenith/canary',
@@ -215,7 +215,7 @@ describe('parseBrandBuildMatrix', () => {
215215
};
216216
const second = brand('zenith');
217217
second.distribution.desktop = {
218-
credentialEnvironment: 'release-zenith',
218+
credentialEnvironment: 'release',
219219
r2Bucket: first.distribution.desktop.r2Bucket,
220220
r2Prefix: first.distribution.desktop.r2Prefix,
221221
updateUrl: 'https://zenith.example.invalid/desktop/acme/zenith/canary',
@@ -244,7 +244,7 @@ describe('parseBrandBuildMatrix', () => {
244244
RE_WRONG_CREDENTIAL_ENVIRONMENT,
245245
);
246246

247-
second.distribution.desktop.credentialEnvironment = 'release-zenith';
247+
second.distribution.desktop.credentialEnvironment = 'release';
248248
second.distribution.mobile.ios.ascAppId = first.distribution.mobile.ios.ascAppId;
249249
expect(() => parseBrandBuildMatrix(matrix(first, second), { build: true })).toThrow(
250250
RE_SHARED_APP_STORE_APP,
@@ -327,7 +327,7 @@ describe('release brand matrix workflow', () => {
327327
expect(preflight).toContain(
328328
'expected=\'[{"name":"master","type":"branch"},{"name":"v*.*.*","type":"tag"}]\'',
329329
);
330-
expect(preflight).toContain('credentialEnvironment');
330+
expect(preflight).not.toContain('credentialEnvironment');
331331
expect(preflight).toContain(`GH_TOKEN: ${ACTIONS_EXPRESSION}{{ github.token }}`);
332332
expect(preflight).not.toContain('RELEASE_ENVIRONMENT_ADMIN_TOKEN');
333333
expect(workflow).toContain('actions: read');

.github/workflows/release-brand-matrix.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,6 @@ jobs:
239239
fi
240240
}
241241
check_environment release true
242-
while IFS= read -r environment; do
243-
check_environment "$environment" false
244-
done < <(jq -r '.include[].distribution.desktop.credentialEnvironment' <<<"$BRANDS_JSON")
245242
246243
render-inputs:
247244
name: Validate immutable render inputs

apps/desktop/src/build/__tests__/electron-builder-brand.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ describe('electronBuilderBrandConfig', () => {
6666
expect(serialized.replaceAll('./electron-builder.yml', '')).not.toMatch(/linkcode/i);
6767
});
6868

69+
it('uses the publisher-resolved LinkCode desktop app id verbatim', () => {
70+
const config = electronBuilderBrandConfig(
71+
identity({
72+
applicationId: 'com.arcboxlabs.linkcode.desktop',
73+
brandId: 'linkcode',
74+
displayName: 'LinkCode',
75+
storageNamespace: 'LinkCode',
76+
urlScheme: 'linkcode',
77+
}),
78+
);
79+
80+
expect(config.appId).toBe('com.arcboxlabs.linkcode.desktop');
81+
});
82+
6983
it('serializes deterministically', () => {
7084
const first = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY));
7185
const second = serializeElectronBuilderBrandConfig(electronBuilderBrandConfig(ZENITH_CANARY));

apps/mobile/src/build/__tests__/expo-brand.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ describe('deriveExpoBrandOverlay', () => {
7070
});
7171
});
7272

73+
it('uses the publisher-resolved LinkCode mobile ids verbatim', () => {
74+
const linkcode = deriveExpoBrandOverlay(
75+
identity('ios', {
76+
applicationId: 'com.arcboxlabs.linkcode.mobile',
77+
brandId: 'linkcode',
78+
displayName: 'LinkCode',
79+
storageNamespace: 'LinkCode',
80+
urlScheme: 'linkcode',
81+
}),
82+
identity('android', {
83+
applicationId: 'com.arcboxlabs.linkcode.mobile',
84+
brandId: 'linkcode',
85+
displayName: 'LinkCode',
86+
storageNamespace: 'LinkCode',
87+
urlScheme: 'linkcode',
88+
}),
89+
);
90+
91+
expect(linkcode.iosBundleIdentifier).toBe('com.arcboxlabs.linkcode.mobile');
92+
expect(linkcode.androidPackage).toBe('com.arcboxlabs.linkcode.mobile');
93+
});
94+
7395
it('fails closed on swapped platforms', () => {
7496
expect(() => deriveExpoBrandOverlay(identity('android'), identity('android'))).toThrow(
7597
/expected an ios identity/,

docs/ENVIRONMENT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ Set as GitHub repository/environment secrets, never locally. Signing and notariz
128128
| `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. |
129129
| `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. |
130130
| `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. |
131-
| `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. |
131+
| `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. |
132132
| `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. |
133133

134134
Mobile certificates, provisioning profiles, the Android keystore, the App Store Connect API key,

docs/RELEASE.md

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ nonproduction fixture; no other path is accepted.
151151
checklist with all five keys set to `true`: `configurableFeaturesDisclosed`,
152152
`dataPracticesReviewed`, `noExecutableCode`, `permissionsReviewed`, and `storeMetadataReviewed`.
153153
- `distribution.desktop` may be `null` only for plan validation. Every build requires an object containing
154-
`credentialEnvironment`, `r2Bucket`, `r2Prefix`, and `updateUrl`. The environment must be exactly
155-
`release-<brandId>`. Both URL and prefix must end in the same brand/channel path, and prefixes in
156-
one bucket must not overlap.
154+
`credentialEnvironment`, `r2Bucket`, `r2Prefix`, and `updateUrl`. `credentialEnvironment` must be
155+
exactly `release`; no per-brand Environment is part of this contract. Both URL and prefix must end
156+
in the same brand/channel path, and prefixes in one bucket must not overlap.
157157
- `distribution.mobile` may be `null` only for plan validation. Every build requires `easProjectId`, its
158158
exact `https://u.expo.dev/<id>` URL, iOS `appleTeamId`/`ascAppId`, and Android
159159
`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.
179179

180180
### Required Actions configuration and least privilege
181181

182-
Render vars below are read from the protected `release` environment. Signing, upload, store, and
183-
observability inputs are read from the protected `release-<brandId>` environment selected by the
184-
reviewed matrix. The bot credentials are organization secrets. Trusted workflow steps report
182+
Render vars, signing, upload, store, and observability inputs are read from the single protected
183+
`release` Environment. The reviewed matrix must name that exact Environment for every row. The bot
184+
credentials are organization secrets. Trusted workflow steps report
185185
missing bot credentials before checking out selected client code, and the input scripts report
186186
missing render, signing, or upload values without receiving those bot credentials:
187187

@@ -201,8 +201,8 @@ missing render, signing, or upload values without receiving those bot credential
201201
- Windows Desktop: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_PUBLISHER_NAME`,
202202
`AZURE_SIGN_ENDPOINT`, `AZURE_CODE_SIGNING_ACCOUNT`, and `AZURE_CERTIFICATE_PROFILE`. The Azure
203203
app has only the Trusted Signing certificate-profile signer role and an OIDC subject restricted
204-
to this repository's matching `release-<brandId>` environment; no client secret exists.
205-
- Desktop observability in `release-<brandId>`: `SENTRY_DSN_DESKTOP` and
204+
to this repository's `release` Environment; no client secret exists.
205+
- Desktop observability in `release`: `SENTRY_DSN_DESKTOP` and
206206
`POSTHOG_PROJECT_TOKEN` plus the `POSTHOG_HOST` var. These are required publishable identifiers,
207207
not signing credentials.
208208
- 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
212212
Native certificates, provisioning profiles, Android keystores, App Store Connect keys, and Google
213213
Play service accounts stay EAS-managed and project-scoped. Submissions stop at TestFlight and the
214214
Play internal track; this workflow never submits to App Review or promotes a Play release.
215-
- Desktop upload: `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, and `R2_SECRET_ACCESS_KEY` in each brand's
216-
`release-<brandId>` environment. Each key pair is scoped to that brand's one `r2Bucket/r2Prefix`
217-
with object read/write/list only; it must not access another brand prefix or permit
215+
- Desktop upload: `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, and `R2_SECRET_ACCESS_KEY` in `release`.
216+
Scope the key pair to the exact `r2Bucket/r2Prefix` destinations in the reviewed matrix with
217+
object read/write/list only; it must not permit
218218
bucket/account administration. `R2_ACCOUNT_ID` is exactly the
219219
lowercase 32-hex Cloudflare account ID; URL-like or otherwise malformed values fail before AWS CLI runs.
220220

221221
Do not store private signing material, access tokens, or service-account JSON in the committed
222222
matrix, repository files, artifacts, or Actions vars. Protect `release` with required reviewers and
223-
only exact `master` plus `v*.*.*` custom deployment policies. Protect every `release-<brandId>`
224-
environment with required reviewers and only the exact `master` custom deployment policy before
225-
enabling `build`, `sign`, or `upload`.
223+
only exact `master` plus `v*.*.*` custom deployment policies before enabling `build`, `sign`, or
224+
`upload`. No additional release Environment is required by the brand-matrix workflow.
226225
The environment preflight reads protection metadata with the built-in `GITHUB_TOKEN` and explicit
227226
`actions: read`; this metadata-only token cannot approve or bypass an environment review.
228227

0 commit comments

Comments
 (0)