Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
b08bf04
test: init
LeVinhGithub Mar 12, 2026
20c2194
test: fix prettier
LeVinhGithub Mar 12, 2026
658c54f
test: fix format ci
LeVinhGithub Mar 12, 2026
c96d92f
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 12, 2026
40f754f
test: fix lint+add unittest
LeVinhGithub Mar 12, 2026
dcc70a9
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 12, 2026
519995b
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 12, 2026
4a64d31
test: fix comment
LeVinhGithub Mar 12, 2026
55c1311
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 16, 2026
b70fc31
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 16, 2026
2fd977b
test: fix cursor comment
LeVinhGithub Mar 16, 2026
41b305b
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 16, 2026
a4d9025
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 17, 2026
84486b0
test: fix cursor comment
LeVinhGithub Mar 17, 2026
d2d60f6
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 17, 2026
4a260ac
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 17, 2026
aaf2ce6
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 18, 2026
6270264
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 18, 2026
d078aef
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 19, 2026
b308a0d
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 19, 2026
f669778
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 20, 2026
8cc61ef
test: fix lint
LeVinhGithub Mar 20, 2026
741da4f
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 20, 2026
88933aa
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 21, 2026
6efc330
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 23, 2026
03de4f2
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 23, 2026
b5b1b6e
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 24, 2026
74816ae
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 24, 2026
a1b0d1b
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 24, 2026
32c5b27
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 25, 2026
8506dc9
test: reflect new workflow
LeVinhGithub Mar 25, 2026
3df2806
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 25, 2026
89fe35b
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 25, 2026
c30bb5c
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 25, 2026
ad47a59
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 25, 2026
9bcc59b
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 26, 2026
458c240
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 26, 2026
a3e8e90
test: fix comment
LeVinhGithub Mar 26, 2026
133cc7e
test: update unittest
LeVinhGithub Mar 26, 2026
b3f13ae
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 26, 2026
a83dda0
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 26, 2026
b845427
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 26, 2026
5fd6de7
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 26, 2026
0099675
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 27, 2026
cea7a65
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 27, 2026
69c3590
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 27, 2026
ee71164
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 30, 2026
277b881
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 30, 2026
9e1f052
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 30, 2026
dddc947
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 31, 2026
8ca917e
test: pass new input
LeVinhGithub Mar 31, 2026
59aaa22
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Mar 31, 2026
d2bf37f
test: fix CR
LeVinhGithub Apr 1, 2026
c6cfef4
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Apr 1, 2026
0754e50
test: fix comment
LeVinhGithub Apr 1, 2026
0bc4a67
Merge branch 'main' into harry/MMQA-1525
LeVinhGithub Apr 1, 2026
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
74 changes: 74 additions & 0 deletions .github/workflows/check-feature-flag-registry-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Check Feature Flag Registry Drift PROD

on:
schedule:
# Run every Tuesday at 01:00 UTC
- cron: "0 1 * * 2"
workflow_dispatch:

permissions:
contents: read

jobs:
check-feature-flag-registry:
name: Check feature flag registry against production
environment: default-branch
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "yarn"

- name: Install dependencies with retry
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 #v3.0.2
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: yarn install --immutable

- name: Check for drift and generate report
id: check
shell: bash
run: |
set +e
yarn feature-flags:sync:check 2>&1 | tee sync-report.txt
EXIT="${PIPESTATUS[0]}"
set -e
echo "HAS_DRIFT=$( [ "$EXIT" -eq 1 ] && echo true || echo false )" >> "$GITHUB_OUTPUT"
echo "HAS_ERROR=$( [ "$EXIT" -eq 2 ] && echo true || echo false )" >> "$GITHUB_OUTPUT"
# Succeed for 0 (no drift) and 1 (drift); fail for 2 (API/script error)
[ "$EXIT" -eq 0 ] || [ "$EXIT" -eq 1 ] || exit "$EXIT"

- name: Upload drift report
if: steps.check.outputs.HAS_DRIFT == 'true'
uses: actions/upload-artifact@v4
with:
name: drift-report
path: sync-report.json

- name: Notify Slack on drift
if: ${{ always() && steps.check.outputs.HAS_DRIFT == 'true' }}
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a
with:
webhook: ${{ secrets.SLACK_WEBHOOK_FEATURE_FLAG_DRIFT }}
webhook-type: incoming-webhook
payload: |
{
"text": "*[MetaMask Mobile] Feature Flags Drift Detected in E2E tests vs Prod* :warning:\n\nCheck the workflow run for details: download the drift report JSON artifact and review the report.\n\nYou can run command `yarn feature-flags:sync:update` locally to update the registry.\n\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*[MetaMask Mobile] Feature Flags Drift Detected in E2E tests vs Prod* :warning:\n\nCheck the workflow run for details: download the drift report JSON artifact and review the report.\n\nYou can run command `yarn feature-flags:sync:update` locally to update the registry.\n\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>"
}
}
]
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@
"gen-bundle:android": "yarn react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/main.jsbundle",
"circular:deps": "dpdm ./app/* --circular --exit-code circular:1 --warning=false",
"generate-icons": "yarn ts-node app/component-library/components/Icons/Icon/scripts/generate-assets.ts",
"feature-flags:sync": "ts-node tests/feature-flags/sync-production-flags.ts",
"feature-flags:sync:check": "ts-node tests/feature-flags/sync-production-flags.ts --check",
"feature-flags:sync:update": "ts-node tests/feature-flags/sync-production-flags.ts --update",
"a:watch": "scripts/perps/agentic/interactive-start.sh",
"a:stop": "scripts/perps/agentic/stop-metro.sh",
"a:status": "scripts/perps/agentic/app-state.sh status",
Expand Down
6 changes: 6 additions & 0 deletions tests/feature-flags/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,9 @@ export {
getRegistryEntry,
} from './feature-flag-registry';
export type { FeatureFlagRegistryEntry } from './feature-flag-registry';
export {
compareProductionFlagsToRegistry,
fetchProductionFlags,
} from './sync-production-flags';
export type { SyncResult } from './sync-production-flags';
export { updateRegistryFile } from './sync-production-flags';
297 changes: 297 additions & 0 deletions tests/feature-flags/sync-production-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
jest.mock('prettier', () => ({
default: {
format: (content: string) => Promise.resolve(content),
resolveConfig: () => Promise.resolve(null),
},
}));

/* eslint-disable import/no-nodejs-modules -- Node.js script for CI/sync */
import fs from 'fs';
import {
getProductionRemoteFlagApiResponse,
getProductionRemoteFlagDefaults,
} from './feature-flag-registry';
import {
compareProductionFlagsToRegistry,
updateRegistryFile,
} from './sync-production-flags';

// Synthetic registry fixture matching structure required by updateRegistryFile
// Uses 2-space indent for entries to match entryPattern: ^ flagName:
const REGISTRY_FIXTURE = `/**
* Feature Flag Registry
* Production defaults last synced: 2020-01-01
*/
export const FEATURE_FLAG_REGISTRY = {
flagA: {
name: 'flagA',
type: 'remote',
inProd: true,
productionDefault: true,
status: 'active',
},

flagB: {
name: 'flagB',
type: 'remote',
inProd: false,
productionDefault: false,
status: 'active',
},

flagToRemove: {
name: 'flagToRemove',
type: 'remote',
inProd: true,
productionDefault: { nested: 'old' },
status: 'active',
},
};

// ============================================================================
// Helper Functions
// ============================================================================
`;

describe('compareProductionFlagsToRegistry', () => {
it('detects new flags in production not in registry', () => {
const registryMap = { addSolanaAccount: true };
const prodResponse = [
{ addSolanaAccount: true },
{ brandNewFlag: { enabled: true } },
];
const result = compareProductionFlagsToRegistry(prodResponse, registryMap);

expect(result.newInProduction).toContainEqual({
name: 'brandNewFlag',
value: { enabled: true },
});
expect(result.hasDrift).toBe(true);
});

it('detects value mismatches between registry and production', () => {
const registryMap = { addSolanaAccount: true, addBitcoinAccount: false };
const prodResponse = [
{ addSolanaAccount: false },
{ addBitcoinAccount: false },
];
const result = compareProductionFlagsToRegistry(prodResponse, registryMap);

const addSolanaMismatch = result.valueMismatches.find(
(m) => m.name === 'addSolanaAccount',
);
expect(addSolanaMismatch).toBeDefined();
expect(addSolanaMismatch?.productionValue).toBe(false);
expect(addSolanaMismatch?.registryValue).toBe(true);
expect(result.hasDrift).toBe(true);
});

it('detects flags in registry no longer in production', () => {
const registryMap = { someRemovedFlag: true };
const prodResponse: Record<string, unknown>[] = [];
const result = compareProductionFlagsToRegistry(prodResponse, registryMap);

expect(result.removedFromProduction).toContainEqual({
name: 'someRemovedFlag',
registryValue: true,
});
expect(result.hasDrift).toBe(true);
});

it('returns hasDrift false when production matches registry', () => {
const registryMap = getProductionRemoteFlagDefaults() as Record<
string,
unknown
>;
const prodResponse = getProductionRemoteFlagApiResponse() as Record<
string,
unknown
>[];

const result = compareProductionFlagsToRegistry(prodResponse, registryMap);

expect(result.newInProduction).toHaveLength(0);
expect(result.removedFromProduction).toHaveLength(0);
expect(result.valueMismatches).toHaveLength(0);
expect(result.inProdMismatches).toHaveLength(0);
expect(result.hasDrift).toBe(false);
});

it('detects inProd mismatch when flag exists with inProd false but is in production', () => {
const registryMap: Record<string, unknown> = {};
const prodResponse = [{ staleInProdFlag: true }];
const fullRegistryOverride = { staleInProdFlag: { inProd: false } };

const result = compareProductionFlagsToRegistry(
prodResponse,
registryMap,
fullRegistryOverride,
);

expect(result.inProdMismatches).toContainEqual({
name: 'staleInProdFlag',
productionValue: true,
});
expect(result.newInProduction).toHaveLength(0);
expect(result.hasDrift).toBe(true);
});

it('skips excluded flags (e.g. mobileMinimumVersions)', () => {
const registryMap = { mobileMinimumVersions: { ios: '1.0.0' } };
const prodResponse = [{ mobileMinimumVersions: { ios: '7.70.0' } }];
const result = compareProductionFlagsToRegistry(prodResponse, registryMap);

expect(result.valueMismatches).toHaveLength(0);
expect(result.newInProduction).toHaveLength(0);
expect(result.removedFromProduction).toHaveLength(0);
expect(result.inProdMismatches).toHaveLength(0);
expect(result.hasDrift).toBe(false);
});

it('parses production API format (array of single-key objects)', () => {
const registryMap: Record<string, unknown> = {};
const prodResponse = [
{ flagA: true },
{ flagB: { nested: 'value' } },
{ flagC: [1, 2, 3] },
];
const result = compareProductionFlagsToRegistry(prodResponse, registryMap);

expect(result.newInProduction).toContainEqual({
name: 'flagA',
value: true,
});
expect(result.newInProduction).toContainEqual({
name: 'flagB',
value: { nested: 'value' },
});
expect(result.newInProduction).toContainEqual({
name: 'flagC',
value: [1, 2, 3],
});
});
});

describe('updateRegistryFile', () => {
const originalReadFileSync = fs.readFileSync;
let readFileSyncMock: jest.SpyInstance;
let writeFileSyncMock: jest.SpyInstance;
let consoleLogSpy: jest.SpyInstance;

beforeEach(() => {
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
readFileSyncMock = jest
.spyOn(fs, 'readFileSync')
.mockImplementation(
(path: fs.PathOrFileDescriptor, encoding?: unknown) => {
if (encoding === 'utf-8') {
return REGISTRY_FIXTURE;
}
return originalReadFileSync.call(
fs,
path,
encoding as BufferEncoding,
);
},
);
writeFileSyncMock = jest.spyOn(fs, 'writeFileSync').mockImplementation();
});

afterEach(() => {
consoleLogSpy.mockRestore();
readFileSyncMock.mockRestore();
writeFileSyncMock.mockRestore();
});

it('updates productionDefault for value mismatches', async () => {
const result = {
newInProduction: [],
removedFromProduction: [],
valueMismatches: [
{ name: 'flagA', productionValue: false, registryValue: true },
],
inProdMismatches: [],
hasDrift: true,
};
await updateRegistryFile(result);
expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
const written = writeFileSyncMock.mock.calls[0][1] as string;
expect(written).toContain('productionDefault: false');
expect(written).toMatch(/flagA:\s*\{[^}]*productionDefault:\s*false/);
expect(written).not.toMatch(/flagA:\s*\{[\s\S]*?productionDefault:\s*true/);
});

it('removes entries no longer in production', async () => {
const result = {
newInProduction: [],
removedFromProduction: [
{ name: 'flagToRemove', registryValue: { nested: 'old' } },
],
valueMismatches: [],
inProdMismatches: [],
hasDrift: true,
};
await updateRegistryFile(result);
expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
const written = writeFileSyncMock.mock.calls[0][1] as string;
expect(written).not.toContain('flagToRemove');
expect(written).toContain('flagA');
});

it('adds new flag entries before Helper Functions', async () => {
const result = {
newInProduction: [{ name: 'brandNewFlag', value: true }],
removedFromProduction: [],
valueMismatches: [],
inProdMismatches: [],
hasDrift: true,
};
await updateRegistryFile(result);
expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
const written = writeFileSyncMock.mock.calls[0][1] as string;
expect(written).toContain('brandNewFlag');
expect(written).toMatch(/brandNewFlag:\s*\{/);
expect(written).toContain('type: FeatureFlagType.Remote');
expect(written).toContain('status: FeatureFlagStatus.Active');
expect(written).toContain('productionDefault: true');
expect(written).toContain('inProd: true');
const helperIdx = written.indexOf('// Helper Functions');
const newFlagIdx = written.indexOf('brandNewFlag');
expect(newFlagIdx).toBeLessThan(helperIdx);
});

it('flips inProd false to true and updates productionDefault for inProdMismatches', async () => {
const result = {
newInProduction: [],
removedFromProduction: [],
valueMismatches: [],
inProdMismatches: [{ name: 'flagB', productionValue: true }],
hasDrift: true,
};
await updateRegistryFile(result);
expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
const written = writeFileSyncMock.mock.calls[0][1] as string;
expect(written).toContain('flagB');
expect(written).toMatch(/flagB:\s*\{[^}]*inProd:\s*true/);
expect(written).toMatch(/flagB:\s*\{[^}]*productionDefault:\s*true/);
expect(written).not.toMatch(/flagB:\s*\{[\s\S]*?inProd:\s*false/);
});

it('updates the last synced date in the header comment', async () => {
const result = {
newInProduction: [],
removedFromProduction: [],
valueMismatches: [],
inProdMismatches: [],
hasDrift: false,
};
await updateRegistryFile(result);
const today = new Date().toISOString().split('T')[0];
expect(writeFileSyncMock).toHaveBeenCalledTimes(1);
const written = writeFileSyncMock.mock.calls[0][1] as string;
expect(written).toContain(`Production defaults last synced: ${today}`);
expect(written).not.toContain(
'Production defaults last synced: 2020-01-01',
);
});
});
Loading
Loading