From 73a6105118b3363baa5f146023dccc67eb119d5e Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Tue, 7 Apr 2026 11:25:50 -0500 Subject: [PATCH 01/17] fix(amplitude-session): prevent 0-second sessions from iOS Background Fetch iOS Background Fetch can briefly trigger AppState 'active' without user interaction, causing rapid session creation/destruction cycles. Add a 1-second timestamp guard in onForeground() to prevent new sessions from starting within 1 second of the last session creation. Co-Authored-By: Claude Opus 4.6 --- .../src/AmplitudeSessionPlugin.tsx | 7 ++++ .../__tests__/AmplitudeSessionPlugin.test.ts | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx index 95bebe04e..196a17f18 100644 --- a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx +++ b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx @@ -207,6 +207,13 @@ export class AmplitudeSessionPlugin extends EventPlugin { }; private onForeground = () => { + // Guard against rapid session creation from iOS Background Fetch. + // iOS can briefly trigger AppState 'active' during background tasks, + // causing 0-second sessions from rapid foreground/background cycles. + const now = Date.now(); + if (this.sessionId > 0 && now - this.sessionId < 1000) { + return; + } this.startNewSessionIfNecessary(); }; diff --git a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts index 17eeaf5d4..683c6cac1 100644 --- a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts +++ b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts @@ -618,6 +618,45 @@ describe('AmplitudeSessionPlugin', () => { expect(plugin.analytics?.track).not.toHaveBeenCalled(); }); + it('should NOT start new session on rapid foreground cycles (iOS Background Fetch)', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + + // Session was just created (less than 1 second ago) + plugin.sessionId = baseTime - 500; // 500ms ago + plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 10000); // expired lastEventTime + + const startNewSessionSpy = jest.spyOn( + plugin as any, + 'startNewSessionIfNecessary' + ); + + // Simulate rapid foreground from Background Fetch + appStateChangeHandler('active'); + + // Should NOT call startNewSessionIfNecessary due to 1-second guard + expect(startNewSessionSpy).not.toHaveBeenCalled(); + }); + + it('should allow new session on foreground when session is older than 1 second', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + + // Session was created more than 1 second ago and has expired + plugin.sessionId = baseTime - 2000; // 2 seconds ago + plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 10000); // expired + + const startNewSessionSpy = jest.spyOn( + plugin as any, + 'startNewSessionIfNecessary' + ); + + appStateChangeHandler('active'); + + // Should call startNewSessionIfNecessary since session is >1s old + expect(startNewSessionSpy).toHaveBeenCalled(); + }); + it('should update lastEventTime when app goes to background', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); From 5292b29c08ab4c02e08b2382c5ec9f3276566000 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Tue, 7 Apr 2026 11:52:13 -0500 Subject: [PATCH 02/17] fix(amplitude-session): recover lastEventTime on partial persist Replace incorrect 1-second timestamp guard with proper root cause fix. The 0-second session bug is caused by non-atomic AsyncStorage persistence: when the app is killed (e.g. during iOS Background Fetch), sessionId may persist while lastEventTime does not. On relaunch, lastEventTime === -1 with a valid sessionId falsely triggers session expiration, ending the just-created session immediately. The fix recovers lastEventTime from sessionId when partial persistence is detected, preventing the false expiration. Also adds hypothesis-driven reproduction tests that verify the fix. Co-Authored-By: Claude Opus 4.6 --- .../src/AmplitudeSessionPlugin.tsx | 16 +- .../__tests__/AmplitudeSessionPlugin.test.ts | 503 ++++++++++++++++-- 2 files changed, 472 insertions(+), 47 deletions(-) diff --git a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx index 196a17f18..11078e1f3 100644 --- a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx +++ b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx @@ -207,13 +207,6 @@ export class AmplitudeSessionPlugin extends EventPlugin { }; private onForeground = () => { - // Guard against rapid session creation from iOS Background Fetch. - // iOS can briefly trigger AppState 'active' during background tasks, - // causing 0-second sessions from rapid foreground/background cycles. - const now = Date.now(); - if (this.sessionId > 0 && now - this.sessionId < 1000) { - return; - } this.startNewSessionIfNecessary(); }; @@ -226,11 +219,18 @@ export class AmplitudeSessionPlugin extends EventPlugin { return; } + // If lastEventTime was lost but sessionId is valid (partial persistence + // failure, e.g. app killed before all AsyncStorage writes complete), + // recover lastEventTime from sessionId to avoid falsely expiring the session. + if (this.lastEventTime === -1 && this.sessionId >= 0) { + this.lastEventTime = this.sessionId; + } + const current = Date.now(); const withinSessionLimit = this.withinMinSessionTime(current); const isSessionExpired = - this.sessionId === -1 || this.lastEventTime === -1 || !withinSessionLimit; + this.sessionId === -1 || !withinSessionLimit; if (this.sessionId >= 0 && !isSessionExpired) { return; diff --git a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts index 683c6cac1..6dfb77e68 100644 --- a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts +++ b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts @@ -618,45 +618,6 @@ describe('AmplitudeSessionPlugin', () => { expect(plugin.analytics?.track).not.toHaveBeenCalled(); }); - it('should NOT start new session on rapid foreground cycles (iOS Background Fetch)', async () => { - const baseTime = Date.now(); - jest.setSystemTime(baseTime); - - // Session was just created (less than 1 second ago) - plugin.sessionId = baseTime - 500; // 500ms ago - plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 10000); // expired lastEventTime - - const startNewSessionSpy = jest.spyOn( - plugin as any, - 'startNewSessionIfNecessary' - ); - - // Simulate rapid foreground from Background Fetch - appStateChangeHandler('active'); - - // Should NOT call startNewSessionIfNecessary due to 1-second guard - expect(startNewSessionSpy).not.toHaveBeenCalled(); - }); - - it('should allow new session on foreground when session is older than 1 second', async () => { - const baseTime = Date.now(); - jest.setSystemTime(baseTime); - - // Session was created more than 1 second ago and has expired - plugin.sessionId = baseTime - 2000; // 2 seconds ago - plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 10000); // expired - - const startNewSessionSpy = jest.spyOn( - plugin as any, - 'startNewSessionIfNecessary' - ); - - appStateChangeHandler('active'); - - // Should call startNewSessionIfNecessary since session is >1s old - expect(startNewSessionSpy).toHaveBeenCalled(); - }); - it('should update lastEventTime when app goes to background', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); @@ -805,4 +766,468 @@ describe('AmplitudeSessionPlugin', () => { }); }); }); + + /** + * ============================================================ + * 0-SECOND SESSION REPRODUCTION TESTS + * ============================================================ + * + * A "0-second session" means: + * - session_start(X) is tracked + * - session_end(X) is tracked (with the same session_id) + * - The time between them is < 1 second + * + * For session_end(X) to fire, startNewSession() must be called + * a SECOND time after session X was created, finding it "expired" + * or otherwise needing replacement. Each test below attempts a + * different hypothesis for how that can happen. + */ + describe('0-second session reproduction', () => { + const makeEvent = (id = 'msg-1'): TrackEventType => ({ + type: EventType.TrackEvent, + event: 'test_event', + properties: {}, + messageId: id, + timestamp: '2023-01-01T00:00:00.000Z', + anonymousId: 'anon-1', + }); + + /** + * Helper: collect all session events from the mock client. + * Returns pairs of { type, session_id, time } for analysis. + */ + const getSessionEvents = (trackMock: jest.Mock) => { + return trackMock.mock.calls + .filter( + (call: any) => + call[0] === 'session_start' || call[0] === 'session_end' + ) + .map((call: any) => ({ + type: call[0] as string, + session_id: call[1]?.integrations?.['Actions Amplitude']?.session_id as number, + callOrder: trackMock.mock.calls.indexOf(call), + })); + }; + + /** + * Helper: detect 0-second sessions in the event log. + * Returns any session_id that has both a start and end. + */ + const findZeroSecondSessions = (trackMock: jest.Mock) => { + const events = getSessionEvents(trackMock); + const starts = events.filter((e: any) => e.type === 'session_start'); + const ends = events.filter((e: any) => e.type === 'session_end'); + + const zeroSessions: number[] = []; + for (const end of ends) { + const matchingStart = starts.find( + (s: any) => s.session_id === end.session_id + ); + if (matchingStart) { + zeroSessions.push(end.session_id); + } + } + return zeroSessions; + }; + + describe('Hypothesis A: AsyncStorage partial persistence failure', () => { + /** + * Scenario: App creates session, sessionId is persisted but + * lastEventTime write fails (app killed mid-write). On relaunch, + * loadSessionData() finds sessionId but lastEventTime = -1. + * + * With the fix: lastEventTime is recovered from sessionId, so the + * session is NOT falsely expired. + */ + it('should recover lastEventTime from sessionId when lastEventTime lost', async () => { + const baseTime = 1000000000000; + jest.setSystemTime(baseTime); + + // Simulate: previous launch created session at baseTime, + // sessionId write succeeded, lastEventTime write failed. + mockAsyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === 'previous_session_id') return baseTime.toString(); + if (key === 'last_event_time') return null; // FAILED to persist + if (key === 'event_session_id') return baseTime.toString(); + return null; + }); + + const { client } = await setupPluginWithClient(); + + // Advance time by just 1ms (NOT 5 minutes) + jest.setSystemTime(baseTime + 1); + + // First event after relaunch + await plugin.execute(makeEvent()); + + const events = getSessionEvents(client.track); + const zeroSessions = findZeroSecondSessions(client.track); + + // With the fix, the session should be recovered, not expired + expect(zeroSessions).toHaveLength(0); + + // The existing session should be continued (no session_end/session_start) + expect(events).toHaveLength(0); + expect(plugin.sessionId).toBe(baseTime); + }); + + it('should reproduce: lastEventTime persisted but sessionId lost', async () => { + const baseTime = 1000000000000; + jest.setSystemTime(baseTime); + + mockAsyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === 'previous_session_id') return null; // FAILED + if (key === 'last_event_time') return baseTime.toString(); + if (key === 'event_session_id') return null; + return null; + }); + + const { client } = await setupPluginWithClient(); + jest.setSystemTime(baseTime + 1); + + await plugin.execute(makeEvent()); + + const events = getSessionEvents(client.track); + const zeroSessions = findZeroSecondSessions(client.track); + + console.log( + '[Hypothesis A2] Session events:', + JSON.stringify(events, null, 2) + ); + console.log('[Hypothesis A2] 0-second sessions:', zeroSessions); + + if (zeroSessions.length > 0) { + console.error( + `REPRODUCED: 0-second session(s): ${zeroSessions.join(', ')}` + ); + } + }); + }); + + describe('Hypothesis B: Concurrent execute() calls with lazy loadSessionData()', () => { + /** + * Scenario: Two events arrive at execute() concurrently while + * sessionId === -1. Both call loadSessionData(). The first + * creates a session, then loadSessionData() from the second + * call overwrites in-memory state with stale AsyncStorage data, + * forcing a second session creation. + */ + it('should reproduce: two events race through loadSessionData()', async () => { + const baseTime = 1000000000000; + jest.setSystemTime(baseTime); + + // Start with empty storage + mockAsyncStorage.getItem.mockResolvedValue(null); + + const { client } = await setupPluginWithClient(); + + // Force sessionId back to -1 to trigger lazy load path + (plugin as any)._sessionId = -1; + (plugin as any)._lastEventTime = -1; + + // Fire two events concurrently — both should see sessionId === -1 + const p1 = plugin.execute(makeEvent('msg-1')); + const p2 = plugin.execute(makeEvent('msg-2')); + await Promise.all([p1, p2]); + + const events = getSessionEvents(client.track); + const zeroSessions = findZeroSecondSessions(client.track); + + console.log( + '[Hypothesis B] Session events:', + JSON.stringify(events, null, 2) + ); + console.log('[Hypothesis B] 0-second sessions:', zeroSessions); + + if (zeroSessions.length > 0) { + console.error( + `REPRODUCED: 0-second session(s): ${zeroSessions.join(', ')}` + ); + } + }); + + it('should reproduce: loadSessionData() returns stale data after session creation', async () => { + const baseTime = 1000000000000; + jest.setSystemTime(baseTime); + + // Simulate: AsyncStorage reads are slow, return stale null + // even after the plugin has set sessionId in memory. + mockAsyncStorage.getItem.mockImplementation(async () => { + return null; + }); + + const { client } = await setupPluginWithClient(); + + // Force uninitialized state + (plugin as any)._sessionId = -1; + (plugin as any)._lastEventTime = -1; + + // First event creates a session + await plugin.execute(makeEvent('msg-1')); + void plugin.sessionId; // session A is now active + + // Force state back to -1 to simulate loadSessionData() + // overwriting state (as if a concurrent call read stale storage) + (plugin as any)._sessionId = -1; + (plugin as any)._lastEventTime = -1; + plugin.resetPending = false; + + // Second event sees -1, creates another session + jest.setSystemTime(baseTime + 50); // 50ms later + await plugin.execute(makeEvent('msg-2')); + + const events = getSessionEvents(client.track); + const zeroSessions = findZeroSecondSessions(client.track); + + console.log( + '[Hypothesis B2] Session events:', + JSON.stringify(events, null, 2) + ); + console.log('[Hypothesis B2] 0-second sessions:', zeroSessions); + + if (zeroSessions.length > 0) { + console.error( + `REPRODUCED: 0-second session(s): ${zeroSessions.join(', ')}` + ); + } + }); + }); + + describe('Hypothesis C: onForeground() + execute() race with expired session', () => { + /** + * Scenario: AppState changes to 'active' after >5 min. + * onForeground() fires (not awaited) and execute() processes + * a lifecycle event concurrently. Both call + * startNewSessionIfNecessary() with the same expired state. + */ + it('should reproduce: simultaneous onForeground and execute after expiry', async () => { + const baseTime = 1000000000000; + jest.setSystemTime(baseTime); + + const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener'); + const { client } = await setupPluginWithClient(); + const appStateHandler = addEventListenerSpy.mock.calls[0][1]; + + // Set up expired session + plugin.sessionId = baseTime - (MAX_SESSION_TIME_IN_MS + 5000); + plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 5000); + plugin.resetPending = false; + + // Simulate what happens when AppState changes to 'active': + // 1. onForeground() fires (via AppState handler) — not awaited + // 2. An "Application Opened" event arrives at execute() + // Both happen in the same event loop tick. + appStateHandler('active'); + const executePromise = plugin.execute(makeEvent()); + await executePromise; + + // Flush microtasks (use advanceTimersByTime with fake timers) + jest.advanceTimersByTime(0); + await Promise.resolve(); + + const events = getSessionEvents(client.track); + const zeroSessions = findZeroSecondSessions(client.track); + + console.log( + '[Hypothesis C] Session events:', + JSON.stringify(events, null, 2) + ); + console.log('[Hypothesis C] 0-second sessions:', zeroSessions); + + if (zeroSessions.length > 0) { + console.error( + `REPRODUCED: 0-second session(s): ${zeroSessions.join(', ')}` + ); + } + }); + }); + + describe('Hypothesis D: Rapid active→background→active with expired session', () => { + /** + * Scenario: User opens app, immediately backgrounds, immediately + * re-opens — all within milliseconds. Session was expired. + * First foreground creates session A, background updates + * lastEventTime, second foreground might create session B + * (ending A immediately). + */ + it('should reproduce: rapid foreground/background/foreground cycle', async () => { + const baseTime = 1000000000000; + jest.setSystemTime(baseTime); + + const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener'); + const { client } = await setupPluginWithClient(); + const appStateHandler = addEventListenerSpy.mock.calls[0][1]; + + // Set up expired session + plugin.sessionId = baseTime - (MAX_SESSION_TIME_IN_MS + 5000); + plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 5000); + plugin.resetPending = false; + + // Rapid state transitions + appStateHandler('active'); // foreground — should start new session + jest.setSystemTime(baseTime + 10); + appStateHandler('background'); // background — updates lastEventTime + jest.setSystemTime(baseTime + 20); + appStateHandler('active'); // foreground again — does this start another? + + // Flush microtasks + jest.advanceTimersByTime(0); + await Promise.resolve(); + + const events = getSessionEvents(client.track); + const zeroSessions = findZeroSecondSessions(client.track); + + console.log( + '[Hypothesis D] Session events:', + JSON.stringify(events, null, 2) + ); + console.log('[Hypothesis D] 0-second sessions:', zeroSessions); + + if (zeroSessions.length > 0) { + console.error( + `REPRODUCED: 0-second session(s): ${zeroSessions.join(', ')}` + ); + } + }); + }); + + describe('Hypothesis E: resetPending cleared then immediate re-entry', () => { + /** + * Scenario: After session A starts, the session_start event + * re-enters execute() and track() clears resetPending. Then, + * before any user event updates lastEventTime, onForeground() + * fires again (or another code path) and finds the session + * in a state where it appears expired. + */ + it('should reproduce: session_start processing clears lock, then state appears expired', async () => { + const baseTime = 1000000000000; + jest.setSystemTime(baseTime); + + const { client } = await setupPluginWithClient(); + + // Create session A + plugin.sessionId = -1; + plugin.lastEventTime = -1; + plugin.resetPending = false; + + await plugin.execute(makeEvent('trigger-session-a')); + + void plugin.sessionId; // session A created + + // Now simulate: session_start event re-enters track() which + // clears resetPending. Then something corrupts lastEventTime. + // This simulates what might happen if loadSessionData() runs + // again and reads stale storage. + plugin.resetPending = false; // As if session_start was processed + (plugin as any)._lastEventTime = -1; // Stale storage overwrite + + jest.setSystemTime(baseTime + 100); // 100ms later + + await plugin.execute(makeEvent('trigger-session-b')); + + const events = getSessionEvents(client.track); + const zeroSessions = findZeroSecondSessions(client.track); + + console.log( + '[Hypothesis E] Session events:', + JSON.stringify(events, null, 2) + ); + console.log('[Hypothesis E] 0-second sessions:', zeroSessions); + + if (zeroSessions.length > 0) { + console.error( + `REPRODUCED: 0-second session(s): ${zeroSessions.join(', ')}` + ); + } + }); + }); + + describe('Hypothesis F: Background Fetch cold-launch lifecycle', () => { + /** + * Root cause scenario: When Background Fetch cold-launches the app, + * AsyncStorage writes are non-atomic. If the app is killed before + * all writes complete, sessionId may persist while lastEventTime + * is lost. On the next launch, lastEventTime === -1 with a valid + * sessionId previously caused isSessionExpired = true, ending the + * just-created session immediately (0-second session). + * + * The fix recovers lastEventTime from sessionId when persistence + * is partial, preventing the false expiration. + */ + it('should NOT create 0-second session with partial storage persistence', async () => { + const baseTime = 1000000000000; + jest.setSystemTime(baseTime); + + // Previous session partially persisted + const oldSession = baseTime - 1000; // 1 second ago (NOT expired by time) + mockAsyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === 'previous_session_id') return oldSession.toString(); + if (key === 'last_event_time') return null; // Lost + if (key === 'event_session_id') return oldSession.toString(); + return null; + }); + + const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener'); + const { client } = await setupPluginWithClient(); + const appStateHandler = addEventListenerSpy.mock.calls[0][1]; + + // "Application Opened" event from core client during init + const appOpenedEvent: TrackEventType = { + type: EventType.TrackEvent, + event: 'Application Opened', + properties: { from_background: false }, + messageId: 'init-1', + timestamp: new Date(baseTime).toISOString(), + anonymousId: 'anon-1', + }; + + await plugin.execute(appOpenedEvent); + + // AppState reports 'background' (Background Fetch) + jest.setSystemTime(baseTime + 50); + appStateHandler('background'); + + // Later: another cold-launch from Background Fetch + // Simulate reload by loading stale session data again + jest.setSystemTime(baseTime + 100); + + // The new session that was just created — its lastEventTime + // was set by execute() at line 122. But if the app is killed + // before AsyncStorage persists, next load sees old data. + // Simulate: force load from storage that has the NEW sessionId + // but missing lastEventTime + const newSession = plugin.sessionId; + mockAsyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === 'previous_session_id') return newSession.toString(); + if (key === 'last_event_time') return null; // Again lost + if (key === 'event_session_id') return newSession.toString(); + return null; + }); + + // Force re-load (simulating new cold launch) + (plugin as any)._sessionId = -1; + (plugin as any)._lastEventTime = -1; + plugin.resetPending = false; + + // Second "Application Opened" from second cold launch + jest.setSystemTime(baseTime + 200); + await plugin.execute({ + ...appOpenedEvent, + messageId: 'init-2', + timestamp: new Date(baseTime + 200).toISOString(), + }); + + const events = getSessionEvents(client.track); + const zeroSessions = findZeroSecondSessions(client.track); + + // With the fix, no 0-second sessions should be created + expect(zeroSessions).toHaveLength(0); + + // The fix recovers lastEventTime from sessionId, so the existing + // session is continued on both launches — no session_end or + // session_start events are fired at all. + expect(events).toHaveLength(0); + }); + }); + }); }); From 8d6874c1eb7a250f18810a7294b95efcf619744c Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Tue, 14 Apr 2026 10:35:18 -0500 Subject: [PATCH 03/17] style: apply formatter to amplitude session plugin Fix CI formatting check failures. Co-Authored-By: Claude Sonnet 4.5 --- .../src/AmplitudeSessionPlugin.tsx | 3 +-- .../src/__tests__/AmplitudeSessionPlugin.test.ts | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx index 11078e1f3..6ef889681 100644 --- a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx +++ b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx @@ -229,8 +229,7 @@ export class AmplitudeSessionPlugin extends EventPlugin { const current = Date.now(); const withinSessionLimit = this.withinMinSessionTime(current); - const isSessionExpired = - this.sessionId === -1 || !withinSessionLimit; + const isSessionExpired = this.sessionId === -1 || !withinSessionLimit; if (this.sessionId >= 0 && !isSessionExpired) { return; diff --git a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts index 6dfb77e68..d46e90253 100644 --- a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts +++ b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts @@ -804,7 +804,8 @@ describe('AmplitudeSessionPlugin', () => { ) .map((call: any) => ({ type: call[0] as string, - session_id: call[1]?.integrations?.['Actions Amplitude']?.session_id as number, + session_id: call[1]?.integrations?.['Actions Amplitude'] + ?.session_id as number, callOrder: trackMock.mock.calls.indexOf(call), })); }; @@ -1064,11 +1065,11 @@ describe('AmplitudeSessionPlugin', () => { plugin.resetPending = false; // Rapid state transitions - appStateHandler('active'); // foreground — should start new session + appStateHandler('active'); // foreground — should start new session jest.setSystemTime(baseTime + 10); - appStateHandler('background'); // background — updates lastEventTime + appStateHandler('background'); // background — updates lastEventTime jest.setSystemTime(baseTime + 20); - appStateHandler('active'); // foreground again — does this start another? + appStateHandler('active'); // foreground again — does this start another? // Flush microtasks jest.advanceTimersByTime(0); From 453afc81ae0b96810be2ee3c976068703059ce42 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Wed, 15 Apr 2026 21:43:34 -0500 Subject: [PATCH 04/17] feat: use branch-specific prerelease channels instead of beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed from a single "beta" prerelease channel to branch-specific channels: - fix/* → x.x.x-fix.N - feat/* → x.x.x-feat.N - chore/* → x.x.x-chore.N - beta → x.x.x-beta.N (explicit) This provides better semantic meaning for prerelease versions. Instead of all non-master branches being "beta", each category gets its own channel. Examples: - fix/ios-zero-second-sessions → 2.22.1-fix.1 - feat/new-feature → 2.22.1-feat.1 - beta → 2.22.1-beta.1 Updated workflow to use new "Publish-Prerelease" environment instead of "Publish-Beta" to reflect the broader scope. Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/release.yml | 6 +++--- release.config.js | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4dc77483f..95119f48f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,11 +62,11 @@ jobs: GH_TOKEN: ${{ github.token }} release-beta: - name: Release (beta) + name: Release (prerelease) if: inputs.type == 'beta' needs: [ci] runs-on: ubuntu-latest - environment: Publish-Beta + environment: Publish-Prerelease permissions: contents: write issues: write @@ -81,7 +81,7 @@ jobs: - name: Install devbox uses: jetify-com/devbox-install-action@v0.14.0 - - name: Release (beta) + - name: Release (prerelease) run: | BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) devbox run -e GITHUB_REF=refs/heads/$BRANCH_NAME release diff --git a/release.config.js b/release.config.js index 1d914158d..246d1e668 100644 --- a/release.config.js +++ b/release.config.js @@ -2,7 +2,10 @@ module.exports = { branches: [ 'master', { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) - { name: '*', prerelease: 'beta' }, // any other branch = beta prerelease (fixes SemVer compliance) + { name: 'beta', prerelease: 'beta' }, // explicit beta channel + { name: 'fix/*', prerelease: 'fix' }, // fix branches → x.x.x-fix.N + { name: 'feat/*', prerelease: 'feat' }, // feature branches → x.x.x-feat.N + { name: 'chore/*', prerelease: 'chore' }, // chore branches → x.x.x-chore.N ], tagFormat: '${name}-v${version}', plugins: [ From 17856c886f6a5f4e82c577f3328634382f356208 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Wed, 15 Apr 2026 21:44:45 -0500 Subject: [PATCH 05/17] docs: add prerelease setup guide Added comprehensive documentation for the new branch-specific prerelease system including: - GitHub environment setup instructions - npm token vs OIDC setup options - Testing and verification steps - Troubleshooting guide - Migration notes from old beta setup Co-Authored-By: Claude Sonnet 4.5 --- PRERELEASE_SETUP.md | 189 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 PRERELEASE_SETUP.md diff --git a/PRERELEASE_SETUP.md b/PRERELEASE_SETUP.md new file mode 100644 index 000000000..d3335dbb6 --- /dev/null +++ b/PRERELEASE_SETUP.md @@ -0,0 +1,189 @@ +# Prerelease Setup Guide + +## Overview + +This repository uses branch-specific prerelease channels for publishing packages from feature branches, fix branches, etc. Each branch category gets its own npm dist-tag: + +- `fix/*` → `2.22.1-fix.1` (dist-tag: `fix`) +- `feat/*` → `2.22.1-feat.1` (dist-tag: `feat`) +- `chore/*` → `2.22.1-chore.1` (dist-tag: `chore`) +- `beta` → `2.22.1-beta.1` (dist-tag: `beta`) + +## GitHub Environment Setup + +### 1. Create the Publish-Prerelease Environment + +1. Go to: https://github.com/segmentio/analytics-react-native/settings/environments +2. Click "New environment" +3. Name: `Publish-Prerelease` +4. Click "Configure environment" + +### 2. Configure Branch Protection (Optional) + +Since semantic-release now controls which branches can publish based on `release.config.js`, you can either: + +**Option A: Allow any branch** (Recommended) +- Leave "Deployment branches and tags" set to "All branches" +- semantic-release will handle branch filtering + +**Option B: Restrict to specific patterns** +- Select "Protected branches and tags only" +- Add patterns: `fix/*`, `feat/*`, `chore/*`, `beta` + +### 3. Add Required Reviewers (Optional) + +If you want manual approval before publishing: +- Enable "Required reviewers" +- Add reviewers from your team +- Set wait timer if desired + +### 4. Add Environment Secrets + +The environment needs access to npm for publishing. You have two options: + +#### Option A: npm Token (Traditional) + +1. Generate an npm automation token: + ```bash + npm login + npm token create --type=automation + ``` + +2. Add the token as a secret: + - Click "Add secret" + - Name: `NPM_TOKEN` + - Value: `npm_xxx...` (your automation token) + +3. Update the workflow to use the token: + ```yaml + - name: Release (prerelease) + run: | + BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) + devbox run -e GITHUB_REF=refs/heads/$BRANCH_NAME release + env: + GH_TOKEN: ${{ github.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + ``` + +#### Option B: npm Provenance with OIDC (Recommended) + +The current setup uses npm provenance with OIDC, which doesn't require storing an NPM_TOKEN. This is more secure because: +- No long-lived tokens to manage +- Automatic provenance attestation +- Built-in supply chain security + +**No additional npm setup needed!** The workflow already has: +- `id-token: write` permission +- `@semantic-release/npm` with `provenance: true` + +npm will automatically authenticate using GitHub's OIDC provider. + +**Requirements:** +- The `@segment` npm organization must have publishing from GitHub Actions enabled +- The package must be public or the org must be on a paid npm plan + +To verify OIDC is configured: +1. Go to: https://www.npmjs.com/settings/segment/packages +2. Check that "Publish" permissions include GitHub Actions +3. If not, contact npm org admin to enable it + +## Testing the Setup + +### 1. Test with Dry Run + +```bash +# From your feature branch +gh workflow run release.yml -f type=dry-run +``` + +This will: +- Run CI checks +- Simulate the release process +- Show what would be published (without actually publishing) + +### 2. Publish a Prerelease + +```bash +# From a fix/feat/chore branch +gh workflow run release.yml -f type=beta +``` + +This will: +- Run CI checks +- Run E2E tests +- Publish to npm with the appropriate dist-tag +- Create a GitHub release + +### 3. Verify on npm + +```bash +# Check dist-tags +npm dist-tag ls @segment/analytics-react-native + +# Should show something like: +# latest: 2.22.0 +# beta: 2.22.1-beta.1 +# fix: 2.22.1-fix.1 +# feat: 2.22.1-feat.2 +``` + +### 4. Install a Prerelease + +```bash +# Install a specific prerelease channel +npm install @segment/analytics-react-native@fix +npm install @segment/analytics-react-native@feat + +# Or a specific version +npm install @segment/analytics-react-native@2.22.1-fix.1 +``` + +## Troubleshooting + +### "semantic-release says no version will be published" + +Check that your branch name matches one of the configured patterns in `release.config.js`: +- `fix/*` +- `feat/*` +- `chore/*` +- `beta` +- `master` +- Version branches like `1.x` or `1.2.x` + +### "npm publish failed with 403" + +If using Option A (npm token): +- Verify `NPM_TOKEN` is set in the environment secrets +- Check the token has publish permissions: `npm token list` +- Ensure the token hasn't expired + +If using Option B (OIDC): +- Verify the GitHub Actions OIDC provider is configured in npm org settings +- Check that `id-token: write` permission is set in the workflow +- Ensure `provenance: true` is set in semantic-release npm plugin config + +### "Environment branch policy blocking publish" + +If you set "Protected branches only" in the environment: +- Make sure your branch pattern is added to the protection rules +- Or switch to "All branches" and rely on semantic-release filtering + +## How It Works + +1. **Branch Detection**: When you run the release workflow with `type=beta`, the workflow reads your current branch name +2. **semantic-release Matching**: semantic-release checks if your branch matches any pattern in `release.config.js` +3. **Version Calculation**: Based on conventional commits, it determines the next version and appends the prerelease suffix +4. **npm Publish**: Publishes to npm with the corresponding dist-tag +5. **GitHub Release**: Creates a GitHub release (marked as prerelease) + +## Migrating from Old "Beta" Setup + +The old setup used `{ name: '*', prerelease: 'beta' }` which made ALL non-master branches publish as "beta". This was confusing because: +- Fix branches weren't actually beta releases +- You couldn't have multiple prerelease channels +- The GitHub environment was called "Publish-Beta" but handled all prereleases + +The new setup is more explicit and semantically correct: +- Each branch category gets its own channel +- The environment is now "Publish-Prerelease" to reflect its broader scope +- "beta" is now an explicit channel for the `beta` branch only From 033a9380ffe72a83bf60db32adbeb562392c4709 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Wed, 15 Apr 2026 21:45:27 -0500 Subject: [PATCH 06/17] refactor: remove chore/* from prerelease channels Removed chore/* branches from prerelease channels since they're for internal changes not meant for client distribution. Only fix/* and feat/* branches (plus explicit beta) will publish now. Co-Authored-By: Claude Sonnet 4.5 --- PRERELEASE_SETUP.md | 20 +++++++++++--------- release.config.js | 1 - 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/PRERELEASE_SETUP.md b/PRERELEASE_SETUP.md index d3335dbb6..0fd61a198 100644 --- a/PRERELEASE_SETUP.md +++ b/PRERELEASE_SETUP.md @@ -2,13 +2,14 @@ ## Overview -This repository uses branch-specific prerelease channels for publishing packages from feature branches, fix branches, etc. Each branch category gets its own npm dist-tag: +This repository uses branch-specific prerelease channels for publishing packages from feature branches and fix branches. Each branch category gets its own npm dist-tag: - `fix/*` → `2.22.1-fix.1` (dist-tag: `fix`) - `feat/*` → `2.22.1-feat.1` (dist-tag: `feat`) -- `chore/*` → `2.22.1-chore.1` (dist-tag: `chore`) - `beta` → `2.22.1-beta.1` (dist-tag: `beta`) +Note: `chore/*` branches do not publish - they're for internal changes not meant for client distribution. + ## GitHub Environment Setup ### 1. Create the Publish-Prerelease Environment @@ -28,7 +29,7 @@ Since semantic-release now controls which branches can publish based on `release **Option B: Restrict to specific patterns** - Select "Protected branches and tags only" -- Add patterns: `fix/*`, `feat/*`, `chore/*`, `beta` +- Add patterns: `fix/*`, `feat/*`, `beta` ### 3. Add Required Reviewers (Optional) @@ -143,12 +144,13 @@ npm install @segment/analytics-react-native@2.22.1-fix.1 ### "semantic-release says no version will be published" Check that your branch name matches one of the configured patterns in `release.config.js`: -- `fix/*` -- `feat/*` -- `chore/*` -- `beta` -- `master` -- Version branches like `1.x` or `1.2.x` +- `fix/*` - bug fixes for client distribution +- `feat/*` - new features for client distribution +- `beta` - explicit beta channel +- `master` - production releases +- Version branches like `1.x` or `1.2.x` - maintenance releases + +Note: `chore/*` branches intentionally don't publish as they're for internal changes. ### "npm publish failed with 403" diff --git a/release.config.js b/release.config.js index 246d1e668..106d1afc8 100644 --- a/release.config.js +++ b/release.config.js @@ -5,7 +5,6 @@ module.exports = { { name: 'beta', prerelease: 'beta' }, // explicit beta channel { name: 'fix/*', prerelease: 'fix' }, // fix branches → x.x.x-fix.N { name: 'feat/*', prerelease: 'feat' }, // feature branches → x.x.x-feat.N - { name: 'chore/*', prerelease: 'chore' }, // chore branches → x.x.x-chore.N ], tagFormat: '${name}-v${version}', plugins: [ From 132a24a3ca0ab9876ef4db4e68f85640fecb6e94 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Wed, 15 Apr 2026 21:51:41 -0500 Subject: [PATCH 07/17] refactor: rename workflow input from 'beta' to 'prerelease' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed the workflow input from 'beta' to 'prerelease' to avoid confusion. The input triggers prerelease publishing for any prerelease channel (fix, feat, or beta), not just the beta channel. Now you select: - dry-run → test without publishing - prerelease → publish fix/feat/beta channels - production → production release from main Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/release.yml | 4 ++-- PRERELEASE_SETUP.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95119f48f..3f7a1edae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: type: choice options: - dry-run - - beta + - prerelease - production concurrency: @@ -63,7 +63,7 @@ jobs: release-beta: name: Release (prerelease) - if: inputs.type == 'beta' + if: inputs.type == 'prerelease' needs: [ci] runs-on: ubuntu-latest environment: Publish-Prerelease diff --git a/PRERELEASE_SETUP.md b/PRERELEASE_SETUP.md index 0fd61a198..38258d0c3 100644 --- a/PRERELEASE_SETUP.md +++ b/PRERELEASE_SETUP.md @@ -94,7 +94,7 @@ To verify OIDC is configured: ```bash # From your feature branch -gh workflow run release.yml -f type=dry-run +gh workflow run release.yml -f type=dry-run --ref fix/your-branch ``` This will: @@ -105,8 +105,8 @@ This will: ### 2. Publish a Prerelease ```bash -# From a fix/feat/chore branch -gh workflow run release.yml -f type=beta +# From a fix/feat branch +gh workflow run release.yml -f type=prerelease --ref fix/your-branch ``` This will: From 7972769719ef3e4b315732f25093a97164144c67 Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Wed, 15 Apr 2026 22:07:57 -0500 Subject: [PATCH 08/17] docs: add prerelease setup guide --- .husky/commit-msg | 2 +- PRERELEASE_SETUP.md | 19 ++++++++++++++++++- devbox.json | 10 +++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.husky/commit-msg b/.husky/commit-msg index 4c49ae6ae..b3acc4aec 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -yarn commitlint --edit $1 +devbox run -- yarn commitlint --edit $1 diff --git a/PRERELEASE_SETUP.md b/PRERELEASE_SETUP.md index 38258d0c3..33a7ce916 100644 --- a/PRERELEASE_SETUP.md +++ b/PRERELEASE_SETUP.md @@ -24,16 +24,19 @@ Note: `chore/*` branches do not publish - they're for internal changes not meant Since semantic-release now controls which branches can publish based on `release.config.js`, you can either: **Option A: Allow any branch** (Recommended) + - Leave "Deployment branches and tags" set to "All branches" - semantic-release will handle branch filtering **Option B: Restrict to specific patterns** + - Select "Protected branches and tags only" - Add patterns: `fix/*`, `feat/*`, `beta` ### 3. Add Required Reviewers (Optional) If you want manual approval before publishing: + - Enable "Required reviewers" - Add reviewers from your team - Set wait timer if desired @@ -45,12 +48,14 @@ The environment needs access to npm for publishing. You have two options: #### Option A: npm Token (Traditional) 1. Generate an npm automation token: + ```bash npm login npm token create --type=automation ``` 2. Add the token as a secret: + - Click "Add secret" - Name: `NPM_TOKEN` - Value: `npm_xxx...` (your automation token) @@ -69,21 +74,25 @@ The environment needs access to npm for publishing. You have two options: #### Option B: npm Provenance with OIDC (Recommended) The current setup uses npm provenance with OIDC, which doesn't require storing an NPM_TOKEN. This is more secure because: + - No long-lived tokens to manage - Automatic provenance attestation - Built-in supply chain security **No additional npm setup needed!** The workflow already has: + - `id-token: write` permission - `@semantic-release/npm` with `provenance: true` npm will automatically authenticate using GitHub's OIDC provider. **Requirements:** + - The `@segment` npm organization must have publishing from GitHub Actions enabled - The package must be public or the org must be on a paid npm plan To verify OIDC is configured: + 1. Go to: https://www.npmjs.com/settings/segment/packages 2. Check that "Publish" permissions include GitHub Actions 3. If not, contact npm org admin to enable it @@ -98,6 +107,7 @@ gh workflow run release.yml -f type=dry-run --ref fix/your-branch ``` This will: + - Run CI checks - Simulate the release process - Show what would be published (without actually publishing) @@ -110,6 +120,7 @@ gh workflow run release.yml -f type=prerelease --ref fix/your-branch ``` This will: + - Run CI checks - Run E2E tests - Publish to npm with the appropriate dist-tag @@ -144,8 +155,9 @@ npm install @segment/analytics-react-native@2.22.1-fix.1 ### "semantic-release says no version will be published" Check that your branch name matches one of the configured patterns in `release.config.js`: + - `fix/*` - bug fixes for client distribution -- `feat/*` - new features for client distribution +- `feat/*` - new features for client distribution - `beta` - explicit beta channel - `master` - production releases - Version branches like `1.x` or `1.2.x` - maintenance releases @@ -155,11 +167,13 @@ Note: `chore/*` branches intentionally don't publish as they're for internal cha ### "npm publish failed with 403" If using Option A (npm token): + - Verify `NPM_TOKEN` is set in the environment secrets - Check the token has publish permissions: `npm token list` - Ensure the token hasn't expired If using Option B (OIDC): + - Verify the GitHub Actions OIDC provider is configured in npm org settings - Check that `id-token: write` permission is set in the workflow - Ensure `provenance: true` is set in semantic-release npm plugin config @@ -167,6 +181,7 @@ If using Option B (OIDC): ### "Environment branch policy blocking publish" If you set "Protected branches only" in the environment: + - Make sure your branch pattern is added to the protection rules - Or switch to "All branches" and rely on semantic-release filtering @@ -181,11 +196,13 @@ If you set "Protected branches only" in the environment: ## Migrating from Old "Beta" Setup The old setup used `{ name: '*', prerelease: 'beta' }` which made ALL non-master branches publish as "beta". This was confusing because: + - Fix branches weren't actually beta releases - You couldn't have multiple prerelease channels - The GitHub environment was called "Publish-Beta" but handled all prereleases The new setup is more explicit and semantically correct: + - Each branch category gets its own channel - The environment is now "Publish-Prerelease" to reflect its broader scope - "beta" is now an explicit channel for the `beta` branch only diff --git a/devbox.json b/devbox.json index 1fc177a2a..86b0226e9 100644 --- a/devbox.json +++ b/devbox.json @@ -24,11 +24,11 @@ "ci:install": ["yarn install --immutable"], "ci:commitlint": ["bash -c 'echo \"$PR_TITLE\" | yarn commitlint'"], "check": [ - "devbox run lint", - "devbox run format-check", - "devbox run build", - "devbox run typecheck", - "devbox run test" + "yarn lint", + "yarn format:check", + "yarn build", + "yarn typecheck", + "yarn test" ], "build": ["yarn build"], "test": ["yarn test"], From 1965a6d700f24b09273c1b161c92c0c62ffc5de9 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 11:07:52 +0530 Subject: [PATCH 09/17] fix(config): updated branches to support fix/feat --- multi-release.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/multi-release.config.js b/multi-release.config.js index c6a412116..6aa86b25c 100644 --- a/multi-release.config.js +++ b/multi-release.config.js @@ -2,7 +2,9 @@ module.exports = { branches: [ 'master', { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) - { name: '*', prerelease: true }, // any other branch = prerelease + { name: 'beta', prerelease: 'beta' }, // explicit beta channel + { name: 'fix/*', prerelease: 'fix' }, // fix branches → x.x.x-fix.N + { name: 'feat/*', prerelease: 'feat' }, // feature branches → x.x.x-feat.N ], tagFormat: '${name}-v${version}', deps: { From c8c5cf3dd25f9635bfe19c768c94f1ce1618a4dc Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 11:30:33 +0530 Subject: [PATCH 10/17] fix(config): fixed for branches --- multi-release.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/multi-release.config.js b/multi-release.config.js index 6aa86b25c..9fe2b260f 100644 --- a/multi-release.config.js +++ b/multi-release.config.js @@ -2,9 +2,9 @@ module.exports = { branches: [ 'master', { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) - { name: 'beta', prerelease: 'beta' }, // explicit beta channel - { name: 'fix/*', prerelease: 'fix' }, // fix branches → x.x.x-fix.N - { name: 'feat/*', prerelease: 'feat' }, // feature branches → x.x.x-feat.N + { name: 'beta', prerelease: true}, // explicit beta channel + { name: 'fix/*', prerelease: true }, // fix branches → x.x.x-fix.N + { name: 'feat/*', prerelease: true }, // feature branches → x.x.x-feat.N ], tagFormat: '${name}-v${version}', deps: { From d39ed1c26a5f793f54e88815efa780debbc99afa Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 12:48:48 +0530 Subject: [PATCH 11/17] refactor(config): formatted config file --- multi-release.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi-release.config.js b/multi-release.config.js index 9fe2b260f..10dbffb70 100644 --- a/multi-release.config.js +++ b/multi-release.config.js @@ -2,7 +2,7 @@ module.exports = { branches: [ 'master', { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) - { name: 'beta', prerelease: true}, // explicit beta channel + { name: 'beta', prerelease: true }, // explicit beta channel { name: 'fix/*', prerelease: true }, // fix branches → x.x.x-fix.N { name: 'feat/*', prerelease: true }, // feature branches → x.x.x-feat.N ], From 75d48dfca7881224185e9e2ae52f5ad8c02922e3 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 13:05:55 +0530 Subject: [PATCH 12/17] refactor(config): same changes done for release.config --- release.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/release.config.js b/release.config.js index 106d1afc8..0b895fa70 100644 --- a/release.config.js +++ b/release.config.js @@ -2,9 +2,9 @@ module.exports = { branches: [ 'master', { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) - { name: 'beta', prerelease: 'beta' }, // explicit beta channel - { name: 'fix/*', prerelease: 'fix' }, // fix branches → x.x.x-fix.N - { name: 'feat/*', prerelease: 'feat' }, // feature branches → x.x.x-feat.N + { name: 'beta', prerelease: true }, // explicit beta channel + { name: 'fix/*', prerelease: true }, // fix branches → x.x.x-fix.N + { name: 'feat/*', prerelease: true }, // feature branches → x.x.x-feat.N ], tagFormat: '${name}-v${version}', plugins: [ From 0170e6c217fe840180a2e2e5d80feaf1e96ac184 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 13:40:15 +0530 Subject: [PATCH 13/17] fix(config): dynamically detect the current branch at runtime --- multi-release.config.js | 40 +++++++++++++++++++++++++++++++++------- release.config.js | 40 +++++++++++++++++++++++++++++++++------- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/multi-release.config.js b/multi-release.config.js index 10dbffb70..1f1f21f44 100644 --- a/multi-release.config.js +++ b/multi-release.config.js @@ -1,11 +1,37 @@ +const { execSync } = require('child_process'); + +// Detect current branch: GITHUB_REF_NAME (Actions) → GITHUB_REF → git +function getCurrentBranch() { + if (process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME; + if (process.env.GITHUB_REF) + return process.env.GITHUB_REF.replace('refs/heads/', ''); + return execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); +} + +const currentBranch = getCurrentBranch(); + +// Sanitize branch name for semver: replace any non-alphanumeric/hyphen char with '-' +const prerelease = currentBranch + .replace(/[^a-zA-Z0-9-]/g, '-') + .replace(/^-+|-+$/g, ''); + +const isStableBranch = + currentBranch === 'master' || /^\d+(\.\d+)*\.x$/.test(currentBranch); +const isBetaBranch = currentBranch === 'beta'; + +const branches = [ + 'master', + { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) + { name: 'beta', prerelease: 'beta' }, // explicit beta channel +]; + +// Add current branch explicitly with a sanitized (semver-safe) prerelease identifier +if (!isStableBranch && !isBetaBranch) { + branches.push({ name: currentBranch, prerelease }); +} + module.exports = { - branches: [ - 'master', - { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) - { name: 'beta', prerelease: true }, // explicit beta channel - { name: 'fix/*', prerelease: true }, // fix branches → x.x.x-fix.N - { name: 'feat/*', prerelease: true }, // feature branches → x.x.x-feat.N - ], + branches, tagFormat: '${name}-v${version}', deps: { bump: 'satisfy', // Do not trigger a release for every package if the only change is a minor/patch upgrade of dependencies diff --git a/release.config.js b/release.config.js index 0b895fa70..7e3a6f45b 100644 --- a/release.config.js +++ b/release.config.js @@ -1,11 +1,37 @@ +const { execSync } = require('child_process'); + +// Detect current branch: GITHUB_REF_NAME (Actions) → GITHUB_REF → git +function getCurrentBranch() { + if (process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME; + if (process.env.GITHUB_REF) + return process.env.GITHUB_REF.replace('refs/heads/', ''); + return execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); +} + +const currentBranch = getCurrentBranch(); + +// Sanitize branch name for semver: replace any non-alphanumeric/hyphen char with '-' +const prerelease = currentBranch + .replace(/[^a-zA-Z0-9-]/g, '-') + .replace(/^-+|-+$/g, ''); + +const isStableBranch = + currentBranch === 'master' || /^\d+(\.\d+)*\.x$/.test(currentBranch); +const isBetaBranch = currentBranch === 'beta'; + +const branches = [ + 'master', + { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) + { name: 'beta', prerelease: 'beta' }, // explicit beta channel +]; + +// Add current branch explicitly with a sanitized (semver-safe) prerelease identifier +if (!isStableBranch && !isBetaBranch) { + branches.push({ name: currentBranch, prerelease }); +} + module.exports = { - branches: [ - 'master', - { name: '+([0-9])?(.{+([0-9]),x}).x', prerelease: true }, // support branches (e.g., 1.x, 1.2.x) - { name: 'beta', prerelease: true }, // explicit beta channel - { name: 'fix/*', prerelease: true }, // fix branches → x.x.x-fix.N - { name: 'feat/*', prerelease: true }, // feature branches → x.x.x-feat.N - ], + branches, tagFormat: '${name}-v${version}', plugins: [ ['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }], From df23dcd362fec7277c4972df5231545e22244d2c Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 14:35:15 +0530 Subject: [PATCH 14/17] fix(release): added NPM_TOKEN in env --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f7a1edae..c2b2d3e59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,6 +87,7 @@ jobs: devbox run -e GITHUB_REF=refs/heads/$BRANCH_NAME release env: GH_TOKEN: ${{ github.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} release-production: name: Release (production) @@ -112,6 +113,7 @@ jobs: run: devbox run release env: GH_TOKEN: ${{ github.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Update Apps run: devbox run update-apps From 69e5b4b058b909e232b3a4cf625691a727d0aebe Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 15:05:10 +0530 Subject: [PATCH 15/17] fix(config): added channel for prerelease --- multi-release.config.js | 4 ++-- release.config.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/multi-release.config.js b/multi-release.config.js index 1f1f21f44..d9cd0877c 100644 --- a/multi-release.config.js +++ b/multi-release.config.js @@ -25,9 +25,9 @@ const branches = [ { name: 'beta', prerelease: 'beta' }, // explicit beta channel ]; -// Add current branch explicitly with a sanitized (semver-safe) prerelease identifier +// Add current branch explicitly with sanitized (semver-safe) prerelease and dist-tag channel if (!isStableBranch && !isBetaBranch) { - branches.push({ name: currentBranch, prerelease }); + branches.push({ name: currentBranch, prerelease, channel: prerelease }); } module.exports = { diff --git a/release.config.js b/release.config.js index 7e3a6f45b..685c8669b 100644 --- a/release.config.js +++ b/release.config.js @@ -25,9 +25,9 @@ const branches = [ { name: 'beta', prerelease: 'beta' }, // explicit beta channel ]; -// Add current branch explicitly with a sanitized (semver-safe) prerelease identifier +// Add current branch explicitly with sanitized (semver-safe) prerelease and dist-tag channel if (!isStableBranch && !isBetaBranch) { - branches.push({ name: currentBranch, prerelease }); + branches.push({ name: currentBranch, prerelease, channel: prerelease }); } module.exports = { From 971bd27fd9b638a16d6ef3f35d2033d976ba2d27 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 15:29:34 +0530 Subject: [PATCH 16/17] fix: removed NPM_TOKEN --- .github/workflows/release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2b2d3e59..3f7a1edae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,7 +87,6 @@ jobs: devbox run -e GITHUB_REF=refs/heads/$BRANCH_NAME release env: GH_TOKEN: ${{ github.token }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} release-production: name: Release (production) @@ -113,7 +112,6 @@ jobs: run: devbox run release env: GH_TOKEN: ${{ github.token }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Update Apps run: devbox run update-apps From 2c2cd0343715b999b2724d9e85482953362e8e84 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Tue, 21 Apr 2026 15:47:52 +0530 Subject: [PATCH 17/17] refactor: added NPM_Token to release.yml file --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f7a1edae..c2b2d3e59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,6 +87,7 @@ jobs: devbox run -e GITHUB_REF=refs/heads/$BRANCH_NAME release env: GH_TOKEN: ${{ github.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} release-production: name: Release (production) @@ -112,6 +113,7 @@ jobs: run: devbox run release env: GH_TOKEN: ${{ github.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Update Apps run: devbox run update-apps