Skip to content

Commit d297cd5

Browse files
committed
feat(flags): build-time client config overrides via Feature Flags
Inject homeserverList, experiments, and other client config fields from GitHub environment variables at build time via a new inject-client-config script. Adds typed experiment bucketing with rollout percentages, an Experiments panel in developer tools, and CI job summary logging. Includes tests for the bucketing helper and injector.
1 parent fcff9ef commit d297cd5

File tree

17 files changed

+415
-27
lines changed

17 files changed

+415
-27
lines changed

.changeset/add_shorts_support_to_fix_crash.md

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add build-time client config overrides via environment variables, with typed deterministic experiment bucketing helpers for progressive feature rollout and A/B testing.

.changeset/fix_pmp_id_handling.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.github/actions/setup/action.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,36 @@ runs:
3434
env:
3535
INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }}
3636

37+
- name: Inject runtime config overrides
38+
if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }}
39+
shell: bash
40+
working-directory: ${{ github.workspace }}
41+
run: node scripts/inject-client-config.js
42+
env:
43+
CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }}
44+
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }}
45+
46+
- name: Display injected config
47+
if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }}
48+
shell: bash
49+
working-directory: ${{ github.workspace }}
50+
run: |
51+
summary_file="${GITHUB_STEP_SUMMARY:-}"
52+
echo "::group::Injected Client Config"
53+
experiments_json="$(jq -c '.experiments // "No experiments configured"' config.json 2>/dev/null || echo 'config.json not readable')"
54+
echo "$experiments_json"
55+
echo "::endgroup::"
56+
57+
if [[ -n "$summary_file" ]]; then
58+
{
59+
echo "### Injected client config"
60+
echo
61+
echo "\`\`\`json"
62+
echo "$experiments_json"
63+
echo "\`\`\`"
64+
} >> "$summary_file"
65+
fi
66+
3767
- name: Build app
3868
if: ${{ inputs.build == 'true' }}
3969
shell: bash

.github/workflows/cloudflare-web-deploy.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ jobs:
4040
plan:
4141
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
4242
runs-on: ubuntu-latest
43+
environment: preview
44+
env:
45+
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
46+
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
4347
permissions:
4448
contents: read
4549
pull-requests: write
@@ -73,6 +77,10 @@ jobs:
7377
apply:
7478
if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch'
7579
runs-on: ubuntu-latest
80+
environment: production
81+
env:
82+
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
83+
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
7684
permissions:
7785
contents: read
7886
defaults:

.github/workflows/cloudflare-web-preview.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ jobs:
3232
deploy:
3333
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push'
3434
runs-on: ubuntu-latest
35+
environment: preview
3536
permissions:
3637
contents: read
3738
pull-requests: write
39+
env:
40+
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
41+
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
3842
steps:
3943
- name: Checkout repository
4044
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

knip.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://unpkg.com/knip@5/schema.json",
3-
"entry": ["src/sw.ts", "scripts/normalize-imports.js"],
3+
"entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"],
44
"ignoreExportsUsedInFile": {
55
"interface": true,
66
"type": true

scripts/inject-client-config.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { readFile, writeFile } from 'node:fs/promises';
2+
import process from 'node:process';
3+
import { PrefixedLogger } from './utils/console-style.js';
4+
5+
const CONFIG_PATH = 'config.json';
6+
const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON';
7+
const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT';
8+
const logger = new PrefixedLogger('[config-inject]');
9+
10+
const formatError = (error) => {
11+
if (error instanceof Error) return error.stack ?? error.message;
12+
return String(error);
13+
};
14+
15+
const isPlainObject = (value) =>
16+
typeof value === 'object' && value !== null && !Array.isArray(value);
17+
18+
const deepMerge = (target, source) => {
19+
if (!isPlainObject(target) || !isPlainObject(source)) return source;
20+
21+
const merged = { ...target };
22+
Object.entries(source).forEach(([key, value]) => {
23+
const targetValue = merged[key];
24+
merged[key] =
25+
isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value;
26+
});
27+
return merged;
28+
};
29+
30+
const failOnError = process.env[STRICT_ENV] === 'true';
31+
const overridesRaw = process.env[OVERRIDES_ENV];
32+
33+
if (!overridesRaw) {
34+
logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`);
35+
process.exit(0);
36+
}
37+
38+
let fileConfig;
39+
let overrides;
40+
41+
try {
42+
const file = await readFile(CONFIG_PATH, 'utf8');
43+
fileConfig = JSON.parse(file);
44+
} catch (error) {
45+
logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`);
46+
process.exit(1);
47+
}
48+
49+
try {
50+
overrides = JSON.parse(overridesRaw);
51+
if (!isPlainObject(overrides)) {
52+
throw new Error(`${OVERRIDES_ENV} must be a JSON object.`);
53+
}
54+
} catch (error) {
55+
const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${
56+
failOnError ? 'failing build' : 'skipping overrides'
57+
}.`;
58+
if (failOnError) {
59+
logger.error(`${message} ${formatError(error)}`);
60+
process.exit(1);
61+
}
62+
logger.info(`[warning] ${message} ${formatError(error)}`);
63+
process.exit(0);
64+
}
65+
66+
const mergedConfig = deepMerge(fileConfig, overrides);
67+
68+
await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8');
69+
logger.info(
70+
`Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}`
71+
);

src/app/components/url-preview/ClientPreview.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,10 @@ function parseYoutubeLink(url: string): YoutubeLink | null {
167167
const split = path.split('?');
168168
[videoId] = split;
169169
params = split[1]?.split('&');
170-
} else if (url.includes('/shorts/')) {
171-
const split = path.split('/shorts/');
172-
[videoId] = split;
173-
params = split[1]?.split('shorts');
174-
} else if (url.includes('youtube.com')) {
170+
} else {
175171
params = path.split('?')[1].split('&');
176172
videoId = params.find((s) => s.startsWith('v='), params)?.split('v=')[1];
177-
} else return null;
173+
}
178174

179175
if (!videoId) return null;
180176

src/app/features/settings/Persona/PerMessageProfileOverview.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
} from '$hooks/usePerMessageProfile';
77
import { useEffect, useState } from 'react';
88
import { Box, Button, Text } from 'folds';
9-
import { generateShortId } from '$utils/shortIdGen';
109
import { PerMessageProfileEditor } from './PerMessageProfileEditor';
1110

1211
/**
@@ -37,7 +36,7 @@ export function PerMessageProfileOverview() {
3736
<Button
3837
onClick={() => {
3938
const newProfile: PerMessageProfile = {
40-
id: generateShortId(5),
39+
id: crypto.randomUUID(),
4140
name: 'New Profile',
4241
};
4342
addOrUpdatePerMessageProfile(mx, newProfile).then(() => {

0 commit comments

Comments
 (0)