-
Notifications
You must be signed in to change notification settings - Fork 1k
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
Staged rollouts #666
Staged rollouts #666
Changes from all commits
f20ed65
2c17638
a13cc0c
14f3a0a
486c2d5
ac76c0d
f7df740
0369f5b
fcaaecd
40eb2bf
91c719f
63ea62c
7b294a9
e7aaab5
30e99c2
fe88624
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
| [docs](..) / [using](.) / staged-rollouts.md | ||
|:---| | ||
|
||
# Staged Rollouts | ||
|
||
Staged rollouts allow you to distribute the latest version of your app to a subset of users that you can increase over time, similar to rollouts on platforms like Google Play. This feature requires Squirrel.Windows 1.4.0 or above. | ||
|
||
### How to use | ||
|
||
Staged rollouts are controlled by manually editing your `RELEASES` file. Here's an example: | ||
|
||
~~~ | ||
e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 | ||
~~~ | ||
|
||
Now let's ship a new version to 10% of our userbase. | ||
|
||
``` | ||
e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 | ||
0d777ea94c612e8bf1ea7379164caefba6e24463 myapp-1.0.1-delta.nupkg 6030# 10% | ||
85f4d657f8424dd437d1b33cc4511ea7ad86b1a7 myapp-1.0.1-full.nupkg 600752# 10% | ||
``` | ||
|
||
Note that the syntax is `# nn%` - due to a bug in earlier versions of Squirrel.Windows, for now, you *must* put the `#` immediately following the file size, no spaces. Once all of your users have Squirrel 1.4.0 or higher, you can add a space after the `#` (similar to a comment). | ||
|
||
Assuming that this rollout is going well, at some point you can upload a new version of the `RELEASES` file: | ||
|
||
``` | ||
e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 | ||
0d777ea94c612e8bf1ea7379164caefba6e24463 myapp-1.0.1-delta.nupkg 6030# 50% | ||
85f4d657f8424dd437d1b33cc4511ea7ad86b1a7 myapp-1.0.1-full.nupkg 600752# 50% | ||
``` | ||
|
||
When you're confident that this release has gone successfully, you can remove the comment so that 100% of users get the file: | ||
|
||
``` | ||
e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 | ||
0d777ea94c612e8bf1ea7379164caefba6e24463 myapp-1.0.1-delta.nupkg 6030 | ||
85f4d657f8424dd437d1b33cc4511ea7ad86b1a7 myapp-1.0.1-full.nupkg 600752 | ||
``` | ||
|
||
### Handling failed rollouts | ||
|
||
If you want to pull a staged release because it hasn't gone well, you should hand-edit the RELEASES file to completely remove the bad version: | ||
|
||
~~~ | ||
e3f67244e4166a65310c816221a12685c83f8e6f myapp-1.0.0-full.nupkg 600725 | ||
~~~ | ||
|
||
Once you do this, you **must** increment the version number higher than your broken release (in this example, we would need to release MyApp 1.0.2). Because some of your users will be on the broken 1.0.1, releasing a _new_ 1.0.1 would result in them staying on a broken version. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,6 +22,7 @@ public interface IReleaseEntry | |
string EntryAsString { get; } | ||
SemanticVersion Version { get; } | ||
string PackageName { get; } | ||
float? StagingPercentage { get; } | ||
|
||
string GetReleaseNotes(string packageDirectory); | ||
Uri GetIconUrl(string packageDirectory); | ||
|
@@ -36,20 +37,27 @@ public class ReleaseEntry : IEnableLogger, IReleaseEntry | |
[DataMember] public string Query { get; protected set; } | ||
[DataMember] public long Filesize { get; protected set; } | ||
[DataMember] public bool IsDelta { get; protected set; } | ||
[DataMember] public float? StagingPercentage { get; protected set; } | ||
|
||
protected ReleaseEntry(string sha1, string filename, long filesize, bool isDelta, string baseUrl = null, string query = null) | ||
protected ReleaseEntry(string sha1, string filename, long filesize, bool isDelta, string baseUrl = null, string query = null, float? stagingPercentage = null) | ||
{ | ||
Contract.Requires(sha1 != null && sha1.Length == 40); | ||
Contract.Requires(filename != null); | ||
Contract.Requires(filename.Contains(Path.DirectorySeparatorChar) == false); | ||
Contract.Requires(filesize > 0); | ||
|
||
SHA1 = sha1; BaseUrl = baseUrl; Filename = filename; Query = query; Filesize = filesize; IsDelta = isDelta; | ||
SHA1 = sha1; BaseUrl = baseUrl; Filename = filename; Query = query; Filesize = filesize; IsDelta = isDelta; StagingPercentage = stagingPercentage; | ||
} | ||
|
||
[IgnoreDataMember] | ||
public string EntryAsString { | ||
get { return String.Format("{0} {1}{2} {3}", SHA1, BaseUrl, Filename, Filesize); } | ||
get { | ||
if (StagingPercentage != null) { | ||
return String.Format("{0} {1}{2} {3} # {4}", SHA1, BaseUrl, Filename, Filesize, stagingPercentageAsString(StagingPercentage.Value)); | ||
} else { | ||
return String.Format("{0} {1}{2} {3}", SHA1, BaseUrl, Filename, Filesize); | ||
} | ||
} | ||
} | ||
|
||
[IgnoreDataMember] | ||
|
@@ -79,17 +87,24 @@ public Uri GetIconUrl(string packageDirectory) | |
} | ||
|
||
static readonly Regex entryRegex = new Regex(@"^([0-9a-fA-F]{40})\s+(\S+)\s+(\d+)[\r]*$"); | ||
static readonly Regex commentRegex = new Regex(@"#.*$"); | ||
static readonly Regex commentRegex = new Regex(@"\s*#.*$"); | ||
static readonly Regex stagingRegex = new Regex(@"#\s+(\d{1,3})%$"); | ||
public static ReleaseEntry ParseReleaseEntry(string entry) | ||
{ | ||
Contract.Requires(entry != null); | ||
|
||
float? stagingPercentage = null; | ||
var m = stagingRegex.Match(entry); | ||
if (m != null && m.Success) { | ||
stagingPercentage = Single.Parse(m.Groups[1].Value) / 100.0f; | ||
} | ||
|
||
entry = commentRegex.Replace(entry, ""); | ||
if (String.IsNullOrWhiteSpace(entry)) { | ||
return null; | ||
} | ||
|
||
var m = entryRegex.Match(entry); | ||
m = entryRegex.Match(entry); | ||
if (!m.Success) { | ||
throw new Exception("Invalid release entry: " + entry); | ||
} | ||
|
@@ -100,7 +115,7 @@ public static ReleaseEntry ParseReleaseEntry(string entry) | |
|
||
string filename = m.Groups[2].Value; | ||
|
||
// Split the base URL and the filename if an URI is provided, | ||
// Split the base URL and the filename if an URI is provided, | ||
// throws if a path is provided | ||
string baseUrl = null; | ||
string query = null; | ||
|
@@ -122,15 +137,29 @@ public static ReleaseEntry ParseReleaseEntry(string entry) | |
query = uri.Query; | ||
} | ||
} | ||
|
||
if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) > -1) { | ||
throw new Exception("Filename can either be an absolute HTTP[s] URL, *or* a file name"); | ||
} | ||
|
||
long size = Int64.Parse(m.Groups[3].Value); | ||
bool isDelta = filenameIsDeltaFile(filename); | ||
|
||
return new ReleaseEntry(m.Groups[1].Value, filename, size, isDelta, baseUrl, query); | ||
return new ReleaseEntry(m.Groups[1].Value, filename, size, isDelta, baseUrl, query, stagingPercentage); | ||
} | ||
|
||
public bool IsStagingMatch(Guid? userId) | ||
{ | ||
// A "Staging match" is when a user falls into the affirmative | ||
// bucket - i.e. if the staging is at 10%, this user is the one out | ||
// of ten case. | ||
if (!StagingPercentage.HasValue) return true; | ||
if (!userId.HasValue) return false; | ||
|
||
uint val = BitConverter.ToUInt32(userId.Value.ToByteArray(), 12); | ||
|
||
double percentage = ((double)val / (double)UInt32.MaxValue); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems to me, that in the absence of tracking actual downloads/installs, it is just assuming an even distribution of guid-to-integer-conversions? So, a hopeful approximation? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We create the GUID in a Weird Way by taking random bytes that is more likely to ensure a random distribution, it's not just |
||
return percentage < StagingPercentage.Value; | ||
} | ||
|
||
public static IEnumerable<ReleaseEntry> ParseReleaseFile(string fileContents) | ||
|
@@ -150,6 +179,24 @@ public static IEnumerable<ReleaseEntry> ParseReleaseFile(string fileContents) | |
return ret.Any(x => x == null) ? null : ret; | ||
} | ||
|
||
public static IEnumerable<ReleaseEntry> ParseReleaseFileAndApplyStaging(string fileContents, Guid? userToken) | ||
{ | ||
if (String.IsNullOrEmpty(fileContents)) { | ||
return new ReleaseEntry[0]; | ||
} | ||
|
||
fileContents = Utility.RemoveByteOrderMarkerIfPresent(fileContents); | ||
|
||
var ret = fileContents.Split('\n') | ||
.Where(x => !String.IsNullOrWhiteSpace(x)) | ||
.Select(ParseReleaseEntry) | ||
.Where(x => x != null && x.IsStagingMatch(userToken)) | ||
.ToArray(); | ||
|
||
return ret.Any(x => x == null) ? null : ret; | ||
} | ||
|
||
|
||
public static void WriteReleaseFile(IEnumerable<ReleaseEntry> releaseEntries, Stream stream) | ||
{ | ||
Contract.Requires(releaseEntries != null && releaseEntries.Any()); | ||
|
@@ -225,6 +272,11 @@ public static List<ReleaseEntry> BuildReleasesFile(string releasePackagesDir) | |
return entries; | ||
} | ||
|
||
static string stagingPercentageAsString(float percentage) | ||
{ | ||
return String.Format("{0:F0}%", percentage * 100.0); | ||
} | ||
|
||
static bool filenameIsDeltaFile(string filename) | ||
{ | ||
return filename.EndsWith("-delta.nupkg", StringComparison.InvariantCultureIgnoreCase); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,13 +23,14 @@ public CheckForUpdateImpl(string rootAppDirectory) | |
public async Task<UpdateInfo> CheckForUpdate( | ||
string localReleaseFile, | ||
string updateUrlOrPath, | ||
bool ignoreDeltaUpdates = false, | ||
bool ignoreDeltaUpdates = false, | ||
Action<int> progress = null, | ||
IFileDownloader urlDownloader = null) | ||
{ | ||
progress = progress ?? (_ => { }); | ||
|
||
var localReleases = Enumerable.Empty<ReleaseEntry>(); | ||
var stagingId = getOrCreateStagedUserId(); | ||
|
||
bool shouldInitialize = false; | ||
try { | ||
|
@@ -44,11 +45,11 @@ public async Task<UpdateInfo> CheckForUpdate( | |
|
||
string releaseFile; | ||
|
||
var latestLocalRelease = localReleases.Count() > 0 ? | ||
localReleases.MaxBy(x => x.Version).First() : | ||
var latestLocalRelease = localReleases.Count() > 0 ? | ||
localReleases.MaxBy(x => x.Version).First() : | ||
default(ReleaseEntry); | ||
|
||
// Fetch the remote RELEASES file, whether it's a local dir or an | ||
// Fetch the remote RELEASES file, whether it's a local dir or an | ||
// HTTP URL | ||
if (Utility.IsHttpUrl(updateUrlOrPath)) { | ||
if (updateUrlOrPath.EndsWith("/")) { | ||
|
@@ -88,7 +89,7 @@ public async Task<UpdateInfo> CheckForUpdate( | |
|
||
if (!Directory.Exists(updateUrlOrPath)) { | ||
var message = String.Format( | ||
"The directory {0} does not exist, something is probably broken with your application", | ||
"The directory {0} does not exist, something is probably broken with your application", | ||
updateUrlOrPath); | ||
|
||
throw new Exception(message); | ||
|
@@ -97,7 +98,7 @@ public async Task<UpdateInfo> CheckForUpdate( | |
var fi = new FileInfo(Path.Combine(updateUrlOrPath, "RELEASES")); | ||
if (!fi.Exists) { | ||
var message = String.Format( | ||
"The file {0} does not exist, something is probably broken with your application", | ||
"The file {0} does not exist, something is probably broken with your application", | ||
fi.FullName); | ||
|
||
this.Log().Warn(message); | ||
|
@@ -117,15 +118,15 @@ public async Task<UpdateInfo> CheckForUpdate( | |
} | ||
|
||
var ret = default(UpdateInfo); | ||
var remoteReleases = ReleaseEntry.ParseReleaseFile(releaseFile); | ||
var remoteReleases = ReleaseEntry.ParseReleaseFileAndApplyStaging(releaseFile, stagingId); | ||
progress(66); | ||
|
||
if (!remoteReleases.Any()) { | ||
throw new Exception("Remote release File is empty or corrupted"); | ||
} | ||
|
||
ret = determineUpdateInfo(localReleases, remoteReleases, ignoreDeltaUpdates); | ||
|
||
progress(100); | ||
return ret; | ||
} | ||
|
@@ -177,6 +178,37 @@ UpdateInfo determineUpdateInfo(IEnumerable<ReleaseEntry> localReleases, IEnumera | |
|
||
return UpdateInfo.Create(currentRelease, remoteReleases, packageDirectory); | ||
} | ||
|
||
internal Guid? getOrCreateStagedUserId() | ||
{ | ||
var stagedUserIdFile = Path.Combine(rootAppDirectory, "packages", ".betaId"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I couldn't tell from the docs how the rollout determination is happening. It would be nice if the docs gave some background on this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm, so users / developers shouldn't have to know this implementation detail, but I don't see why we can't add it to the docs. It's effectively just a random number, serialized in GUID format There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the most part, you are probably right, but for my 2 cents, I wanted to know at least the high-level view of how it worked before I jumped in to trying it out. |
||
var ret = default(Guid); | ||
|
||
try { | ||
if (!Guid.TryParse(File.ReadAllText(stagedUserIdFile, Encoding.UTF8), out ret)) { | ||
throw new Exception("File was read but contents were invalid"); | ||
} | ||
|
||
this.Log().Info("Using existing staging user ID: {0}", ret.ToString()); | ||
return ret; | ||
} catch (Exception ex) { | ||
this.Log().DebugException("Couldn't read staging user ID, creating a blank one", ex); | ||
} | ||
|
||
var prng = new Random(); | ||
var buf = new byte[4096]; | ||
prng.NextBytes(buf); | ||
|
||
ret = Utility.CreateGuidFromHash(buf); | ||
try { | ||
File.WriteAllText(stagedUserIdFile, ret.ToString(), Encoding.UTF8); | ||
this.Log().Info("Generated new staging user ID: {0}", ret.ToString()); | ||
return ret; | ||
} catch (Exception ex) { | ||
this.Log().WarnException("Couldn't write out staging user ID, this user probably shouldn't get beta anything", ex); | ||
return null; | ||
} | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some comments on how we define a staging match would be 👌.