Skip to content

feat(Runtime): Add Rollout Percentage functionality #81

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion CodePush.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,62 @@ import { SemverVersioning } from './versioning/SemverVersioning'

let NativeCodePush = require("react-native").NativeModules.CodePush;
const PackageMixins = require("./package-mixins")(NativeCodePush);
const RolloutStorage = require("react-native").NativeModules.RolloutStorage;

const DEPLOYMENT_KEY = 'deprecated_deployment_key';
const DEPLOYMENT_KEY = 'deprecated_deployment_key',
ROLLOUT_CACHE_PREFIX = 'CodePushRolloutDecision_',
ROLLOUT_CACHE_KEY = 'CodePushRolloutKey';

function hashDeviceId(deviceId) {
let hash = 0;
for (let i = 0; i < deviceId.length; i++) {
hash = ((hash << 5) - hash) + deviceId.charCodeAt(i);
hash |= 0; // Convert to 32bit int
}
return Math.abs(hash);
}

function getRolloutKey(label, rollout) {
return `${ROLLOUT_CACHE_PREFIX}${label}_rollout_${rollout ?? 100}`;
}

function getBucket(clientId, packageHash) {
const hash = hashDeviceId(`${clientId ?? ''}_${packageHash ?? ''}`);
return (Math.abs(hash) % 100);
}

export async function shouldApplyCodePushUpdate(remotePackage, clientId, onRolloutSkipped) {
if (remotePackage.rollout === undefined || remotePackage.rollout >= 100) {
return true;
}

const rolloutKey = getRolloutKey(remotePackage.label, remotePackage.rollout),
cachedDecision = await RolloutStorage.getItem(rolloutKey);

if (cachedDecision != null) {
// should apply if cachedDecision is true
return cachedDecision === 'true';
}

const bucket = getBucket(clientId, remotePackage.packageHash),
inRollout = bucket < remotePackage.rollout,
prevRolloutCacheKey = await RolloutStorage.getItem(ROLLOUT_CACHE_KEY);

console.log(`[CodePush] Bucket: ${bucket}, rollout: ${remotePackage.rollout} → ${inRollout ? 'IN' : 'OUT'}`);

if(prevRolloutCacheKey)
await RolloutStorage.removeItem(prevRolloutCacheKey);

await RolloutStorage.setItem(ROLLOUT_CACHE_KEY, rolloutKey);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems possible to implement without adding ROLLOUT_CACHE_KEY..
I will take a look while refactoring the code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay pls do share once you refactor.

await RolloutStorage.setItem(rolloutKey, inRollout.toString());

if (!inRollout) {
console.log(`[CodePush] Skipping update due to rollout. Bucket ${bucket} >= rollout ${remotePackage.rollout}`);
onRolloutSkipped?.(remotePackage.label);
}

return inRollout;
}

async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
/*
Expand Down Expand Up @@ -121,6 +175,7 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
package_size: 0,
// not used at runtime.
should_run_binary_version: false,
rollout: latestReleaseInfo.rollout
};

return mapToRemotePackageMetadata(updateInfo);
Expand Down Expand Up @@ -164,6 +219,13 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) {
return null;
} else {
const remotePackage = { ...update, ...PackageMixins.remote() };

// Rollout filtering
const shouldApply = await shouldApplyCodePushUpdate(remotePackage, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped);

if(!shouldApply)
return { skipRollout: true };

remotePackage.failedInstall = await NativeCodePush.isFailedUpdate(remotePackage.packageHash);
return remotePackage;
}
Expand Down Expand Up @@ -193,6 +255,7 @@ function mapToRemotePackageMetadata(updateInfo) {
packageHash: updateInfo.package_hash ?? '',
packageSize: updateInfo.package_size ?? 0,
downloadUrl: updateInfo.download_url ?? '',
rollout: updateInfo.rollout ?? 100,
};
}

Expand Down Expand Up @@ -493,6 +556,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
return CodePush.SyncStatus.UPDATE_INSTALLED;
};

if(remotePackage?.skipRollout){
syncStatusChangeCallback(CodePush.SyncStatus.UP_TO_DATE);
return CodePush.SyncStatus.UP_TO_DATE;
}

const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions);

if (!remotePackage || updateShouldBeIgnored) {
Expand Down Expand Up @@ -609,6 +677,9 @@ let CodePush;
*
* onSyncError: (label: string, error: Error) => void | undefined,
* setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | undefined): void,
*
* onRolloutSkipped: (label: string, error: Error) => void | undefined,
* setOnRolloutSkipped(onRolloutSkippedFunction: (label: string, error: Error) => void | undefined): void,
* }}
*/
const sharedCodePushOptions = {
Expand Down Expand Up @@ -653,6 +724,12 @@ const sharedCodePushOptions = {
if (typeof onSyncErrorFunction !== 'function') throw new Error('Please pass a function to onSyncError');
this.onSyncError = onSyncErrorFunction;
},
onRolloutSkipped: undefined,
setOnRolloutSkipped(onRolloutSkippedFunction) {
if (!onRolloutSkippedFunction) return;
if (typeof onRolloutSkippedFunction !== 'function') throw new Error('Please pass a function to onRolloutSkipped');
this.onRolloutSkipped = onRolloutSkippedFunction;
}
}

function codePushify(options = {}) {
Expand Down Expand Up @@ -688,6 +765,7 @@ function codePushify(options = {}) {
sharedCodePushOptions.setOnDownloadStart(options.onDownloadStart);
sharedCodePushOptions.setOnDownloadSuccess(options.onDownloadSuccess);
sharedCodePushOptions.setOnSyncError(options.onSyncError);
sharedCodePushOptions.setOnRolloutSkipped(options.onRolloutSkipped);

const decorator = (RootComponent) => {
class CodePushComponent extends React.Component {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.facebook.react.bridge.JavaScriptModule;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.microsoft.codepush.react.RolloutStorageModule;
import com.facebook.react.devsupport.interfaces.DevSupportManager;
import com.facebook.react.modules.debug.interfaces.DeveloperSettings;
import com.facebook.react.uimanager.ViewManager;
Expand Down Expand Up @@ -404,10 +405,12 @@ static ReactInstanceManager getReactInstanceManager() {
public List<NativeModule> createNativeModules(ReactApplicationContext reactApplicationContext) {
CodePushNativeModule codePushModule = new CodePushNativeModule(reactApplicationContext, this, mUpdateManager, mTelemetryManager, mSettingsManager);
CodePushDialog dialogModule = new CodePushDialog(reactApplicationContext);
RolloutStorageModule rolloutStorageModule = new RolloutStorageModule(reactApplicationContext);

List<NativeModule> nativeModules = new ArrayList<>();
nativeModules.add(codePushModule);
nativeModules.add(dialogModule);
nativeModules.add(rolloutStorageModule);
return nativeModules;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.microsoft.codepush.react;

import android.content.Context;
import android.content.SharedPreferences;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;

public class RolloutStorageModule extends ReactContextBaseJavaModule {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SettingsManager already handles interactions with SharedPreference.
Was the reason you added a new module instead to separate concerns?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't really want to touch the other modules, hence, added this module, but lmk, if I should instead stick to using SettingsManager?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, no. I was just curious about the reason.
For now, since I’m not familiar with the implementation of SettingsManager, I like the way you implemented.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay great!


private final SharedPreferences prefs;

public RolloutStorageModule(ReactApplicationContext reactContext) {
super(reactContext);
prefs = reactContext.getSharedPreferences("CodePushPrefs", Context.MODE_PRIVATE);
}

@Override
public String getName() {
return "RolloutStorage";
}

@ReactMethod
public void getItem(String key, Promise promise) {
promise.resolve(prefs.getString(key, null));
}

@ReactMethod
public void setItem(String key, String value) {
prefs.edit().putString(key, value).apply();
}

@ReactMethod
public void removeItem(String key) {
prefs.edit().remove(key).apply();
}
}
1 change: 1 addition & 0 deletions cli/commands/createHistoryCommand/createReleaseHistory.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ async function createReleaseHistory(
mandatory: false,
downloadUrl: "",
packageHash: "",
rollout: 100
};

/** @type {ReleaseHistoryInterface} */
Expand Down
3 changes: 3 additions & 0 deletions cli/commands/releaseCommand/addToReleaseHistory.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const fs = require("fs");
* @param identifier {string?}
* @param mandatory {boolean?}
* @param enable {boolean?}
* @param rollout {number?}
* @returns {Promise<void>}
*/
async function addToReleaseHistory(
Expand All @@ -38,6 +39,7 @@ async function addToReleaseHistory(
identifier,
mandatory,
enable,
rollout
) {
const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);

Expand All @@ -54,6 +56,7 @@ async function addToReleaseHistory(
mandatory: mandatory,
downloadUrl: bundleDownloadUrl,
packageHash: packageHash,
rollout: rollout
}

try {
Expand Down
3 changes: 3 additions & 0 deletions cli/commands/releaseCommand/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ program.command('release')
.option('-j, --js-bundle-name <string>', 'JS bundle file name (default-ios: "main.jsbundle" / default-android: "index.android.bundle")')
.option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, false)
.option('--enable <bool>', 'make the release to be enabled', parseBoolean, true)
.option('--rollout <number>', 'rollout percentage (0-100)', parseFloat)
.option('--skip-bundle <bool>', 'skip bundle process', parseBoolean, false)
.option('--skip-cleanup <bool>', 'skip cleanup process', parseBoolean, false)
.option('--output-bundle-dir <string>', 'name of directory containing the bundle file created by the "bundle" command', OUTPUT_BUNDLE_DIR)
Expand All @@ -32,6 +33,7 @@ program.command('release')
* @param {string} options.bundleName
* @param {string} options.mandatory
* @param {string} options.enable
* @param {number} options.rollout
* @param {string} options.skipBundle
* @param {string} options.skipCleanup
* @param {string} options.outputBundleDir
Expand All @@ -54,6 +56,7 @@ program.command('release')
options.bundleName,
options.mandatory,
options.enable,
options.rollout,
options.skipBundle,
options.skipCleanup,
`${options.outputPath}/${options.outputBundleDir}`,
Expand Down
3 changes: 3 additions & 0 deletions cli/commands/releaseCommand/release.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const { addToReleaseHistory } = require("./addToReleaseHistory");
* @param jsBundleName {string}
* @param mandatory {boolean}
* @param enable {boolean}
* @param rollout {number}
* @param skipBundle {boolean}
* @param skipCleanup {boolean}
* @param bundleDirectory {string}
Expand All @@ -52,6 +53,7 @@ async function release(
jsBundleName,
mandatory,
enable,
rollout,
skipBundle,
skipCleanup,
bundleDirectory,
Expand Down Expand Up @@ -82,6 +84,7 @@ async function release(
identifier,
mandatory,
enable,
rollout,
)

if (!skipCleanup) {
Expand Down
3 changes: 3 additions & 0 deletions cli/commands/updateHistoryCommand/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ program.command('update-history')
.option('-c, --config <path>', 'set config file name (JS/TS)', CONFIG_FILE_NAME)
.option('-m, --mandatory <bool>', 'make the release to be mandatory', parseBoolean, undefined)
.option('-e, --enable <bool>', 'make the release to be enabled', parseBoolean, undefined)
.option('--rollout <number>', 'rollout percentage (0-100)', parseFloat, undefined)
/**
* @param {Object} options
* @param {string} options.appVersion
Expand All @@ -21,6 +22,7 @@ program.command('update-history')
* @param {string} options.config
* @param {string} options.mandatory
* @param {string} options.enable
* @param {number} options.rollout
* @return {void}
*/
.action(async (options) => {
Expand All @@ -40,6 +42,7 @@ program.command('update-history')
options.identifier,
options.mandatory,
options.enable,
options.rollout
)
});

Expand Down
3 changes: 3 additions & 0 deletions cli/commands/updateHistoryCommand/updateReleaseHistory.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const path = require('path');
* @param identifier {string?}
* @param mandatory {boolean?}
* @param enable {boolean?}
* @param rollout {number?}
* @returns {Promise<void>}
*/
async function updateReleaseHistory(
Expand All @@ -34,6 +35,7 @@ async function updateReleaseHistory(
identifier,
mandatory,
enable,
rollout
) {
const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier);

Expand All @@ -42,6 +44,7 @@ async function updateReleaseHistory(

if (typeof mandatory === "boolean") updateInfo.mandatory = mandatory;
if (typeof enable === "boolean") updateInfo.enabled = enable;
if (typeof rollout === "number") updateInfo.rollout = rollout;

try {
const JSON_FILE_NAME = `${binaryVersion}.json`;
Expand Down
4 changes: 4 additions & 0 deletions ios/CodePush/RolloutStorage.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#import <React/RCTBridgeModule.h>

@interface RolloutStorage : NSObject <RCTBridgeModule>
@end
22 changes: 22 additions & 0 deletions ios/CodePush/RolloutStorage.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#import "RolloutStorage.h"

@implementation RolloutStorage

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(setItem:(NSString *)key value:(NSString *)value) {
[[NSUserDefaults standardUserDefaults] setObject:value forKey:key];
}

RCT_EXPORT_METHOD(getItem:(NSString *)key
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
NSString *value = [[NSUserDefaults standardUserDefaults] stringForKey:key];
resolve(value);
}

RCT_EXPORT_METHOD(removeItem:(NSString *)key) {
[[NSUserDefaults standardUserDefaults] removeObjectForKey:key];
}

@end
5 changes: 5 additions & 0 deletions typings/react-native-code-push.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ReleaseInfo {
mandatory: boolean;
downloadUrl: string;
packageHash: string;
rollout: number;
}

// from code-push SDK
Expand Down Expand Up @@ -93,6 +94,10 @@ export interface CodePushOptions extends SyncOptions {
* Callback function that is called when sync process failed.
*/
onSyncError?: (label: string, error: Error) => void;
/**
* Callback function that is called when rollout is skipped.
*/
onRolloutSkipped?: (label: string, error: Error) => void;
}

export interface DownloadProgress {
Expand Down