Merge pull request #22 from Squirrel/update-dot-exe

Implement Update.exe
This commit is contained in:
Paul Betts
2014-08-29 10:56:15 -07:00
12 changed files with 3852 additions and 30 deletions

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="ILRepack" version="1.25.0" />
<package id="xunit.runners" version="2.0.0-beta-build2700" />
</packages>

View File

@@ -24,7 +24,7 @@ Setup.exe does the following operations:
Update.exe is a generic client for Squirrel which supports several operations:
* `/install [File.nupkg] [/silent]` - Install the NuPkg file given (or any NuPkg files in the same directory as itself), and launch their applications. If `/silent` is given, don't launch anything. Copy `Update.exe` to the application root directory. Install also writes an entry in Programs and Features which will invoke `/uninstall`.
* `/uninstall` - Completely uninstall the application associated with the directory in which `Update.exe` resides.
* `/download URL` - Check for updates from the given URL and write information about available versions to standard output in JSON format.
* `/update` - Updates the application to the latest version of the files in the packages directory associated with the directory in which `Update.exe` resides.
* `--install [directory] [/silent]` - Install the NuPkg file given (or any NuPkg files in the same directory as itself), and launch their applications. If `/silent` is given, don't launch anything. Copy `Update.exe` to the application root directory. Install also writes an entry in Programs and Features which will invoke `/uninstall`.
* `--uninstall` - Completely uninstall the application associated with the directory in which `Update.exe` resides.
* `--download URL` - Check for updates from the given URL and write information about available versions to standard output in JSON format.
* `--update URL` - Updates the application to the latest version from the remote URL

View File

@@ -52,9 +52,9 @@ int CUpdateRunner::ExtractUpdaterAndRun(wchar_t* lpCommandLine)
break;
}
zr = UnzipItem(zipFile, index, zentry.name);
if (UnzipItem(zipFile, index, zentry.name) != ZR_OK) break;
index++;
} while (zr == ZR_MORE);
} while (zr == ZR_MORE || zr == ZR_OK);
CloseZip(zipFile);
zipResource.Release();
@@ -72,7 +72,14 @@ int CUpdateRunner::ExtractUpdaterAndRun(wchar_t* lpCommandLine)
si.wShowWindow = SW_SHOW;
si.dwFlags = STARTF_USESHOWWINDOW;
if (!CreateProcess(updateExePath, lpCommandLine, NULL, NULL, false, 0, NULL, NULL, &si, &pi)) {
if (!lpCommandLine || wcsnlen_s(lpCommandLine, MAX_PATH) < 1) {
lpCommandLine = L"--install .";
}
wchar_t cmd[MAX_PATH];
swprintf_s(cmd, L"%s %s", updateExePath, lpCommandLine);
if (!CreateProcess(NULL, cmd, NULL, NULL, false, 0, NULL, targetDir, &si, &pi)) {
goto failedExtract;
}

View File

@@ -4,6 +4,7 @@ using System.Diagnostics.Contracts;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Win32;
namespace Squirrel
{
@@ -42,19 +43,37 @@ namespace Squirrel
/// CheckForUpdate</param>
/// <param name="progress">A Observer which can be used to report Progress -
/// will return values from 0-100 and Complete, or Throw</param>
Task ApplyReleases(UpdateInfo updateInfo, Action<int> progress = null);
/// <returns>The path to the installed application (i.e. the path where
/// your package's contents ended up</returns>
Task<string> ApplyReleases(UpdateInfo updateInfo, Action<int> progress = null);
/// <summary>
/// Completely Installs a targeted app
/// </summary>
/// <param name="silentInstall">If true, don't run the app once install completes.</param>
/// <returns>Completion</returns>
Task FullInstall();
Task FullInstall(bool silentInstall);
/// <summary>
/// Completely uninstalls the targeted app
/// </summary>
/// <returns>Completion</returns>
Task FullUninstall();
/// <summary>
/// Creates an entry in Programs and Features based on the currently
/// applied package
/// </summary>
/// <param name="uninstallCmd">The command to run to uninstall, usually update.exe --uninstall</param>
/// <param name="quietSwitch">The switch for silent uninstall, usually --silent</param>
/// <returns>The registry key that was created</returns>
Task<RegistryKey> CreateUninstallerRegistryEntry(string uninstallCmd, string quietSwitch);
/// <summary>
/// Removes the entry in Programs and Features created via
/// CreateUninstallerRegistryEntry
/// </summary>
void RemoveUninstallerRegistryEntry();
}
public static class EasyModeMixin

View File

@@ -26,25 +26,27 @@ namespace Squirrel
this.rootAppDirectory = rootAppDirectory;
}
public async Task ApplyReleases(UpdateInfo updateInfo, Action<int> progress = null)
public async Task<string> ApplyReleases(UpdateInfo updateInfo, bool silentInstall, Action<int> progress = null)
{
progress = progress ?? (_ => { });
var release = await createFullPackagesFromDeltas(updateInfo.ReleasesToApply, updateInfo.CurrentlyInstalledVersion);
progress(10);
await installPackageToAppDir(updateInfo, release);
var ret = await installPackageToAppDir(updateInfo, release);
progress(30);
var currentReleases = await updateLocalReleasesFile();
progress(50);
var newVersion = currentReleases.MaxBy(x => x.Version).First().Version;
await invokePostInstall(newVersion, currentReleases.Count == 1);
await invokePostInstall(newVersion, currentReleases.Count == 1 && !silentInstall);
progress(75);
await cleanDeadVersions(newVersion);
progress(100);
return ret;
}
public async Task FullUninstall()
@@ -55,13 +57,13 @@ namespace Squirrel
var version = currentRelease.Name.ToVersion();
await SquirrelAwareExecutableDetector.GetAllSquirrelAwareApps(currentRelease.FullName)
.ForEachAsync(exe => Utility.InvokeProcessAsync(exe, String.Format("/squirrel-uninstall {0}", version)), 1);
.ForEachAsync(exe => Utility.InvokeProcessAsync(exe, String.Format("--squirrel-uninstall {0}", version)), 1);
}
await Utility.DeleteDirectoryWithFallbackToNextReboot(rootAppDirectory);
}
async Task installPackageToAppDir(UpdateInfo updateInfo, ReleaseEntry release)
async Task<string> installPackageToAppDir(UpdateInfo updateInfo, ReleaseEntry release)
{
var pkg = new ZipPackage(Path.Combine(updateInfo.PackageDirectory, release.Filename));
var target = getDirectoryForRelease(release.Version);
@@ -98,6 +100,7 @@ namespace Squirrel
// which shortcuts to install, and nuking them. Then, run the app's
// post install and set up shortcuts.
runPostInstallAndCleanup(newCurrentVersion, updateInfo.IsBootstrapping);
return target.FullName;
}
void CopyFileToLocation(FileSystemInfo target, IPackageFile x)
@@ -190,8 +193,8 @@ namespace Squirrel
{
var targetDir = getDirectoryForRelease(currentVersion);
var args = isInitialInstall ?
String.Format("/squirrel-install {0}", currentVersion) :
String.Format("/squirrel-updated {0}", currentVersion);
String.Format("--squirrel-install {0}", currentVersion) :
String.Format("--squirrel-updated {0}", currentVersion);
var squirrelApps = SquirrelAwareExecutableDetector.GetAllSquirrelAwareApps(targetDir.FullName);
@@ -211,7 +214,7 @@ namespace Squirrel
.ToList();
}
squirrelApps.ForEach(exe => Process.Start(exe, "/squirrel-firstrun"));
squirrelApps.ForEach(exe => Process.Start(exe, "--squirrel-firstrun"));
}
void fixPinnedExecutables(Version newCurrentVersion)

View File

@@ -1,13 +1,16 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.AccessControl;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Win32;
using NuGet;
using Splat;
@@ -57,19 +60,23 @@ namespace Squirrel
await downloadReleases.DownloadReleases(updateUrlOrPath, releasesToDownload, progress, urlDownloader);
}
public async Task ApplyReleases(UpdateInfo updateInfo, Action<int> progress = null)
public async Task<string> ApplyReleases(UpdateInfo updateInfo, Action<int> progress = null)
{
var applyReleases = new ApplyReleasesImpl(rootAppDirectory);
await acquireUpdateLock();
await applyReleases.ApplyReleases(updateInfo, progress);
return await applyReleases.ApplyReleases(updateInfo, false, progress);
}
public async Task FullInstall()
public async Task FullInstall(bool silentInstall = false)
{
var updateInfo = await CheckForUpdate();
await DownloadReleases(updateInfo.ReleasesToApply);
await ApplyReleases(updateInfo);
var applyReleases = new ApplyReleasesImpl(rootAppDirectory);
await acquireUpdateLock();
await applyReleases.ApplyReleases(updateInfo, silentInstall);
}
public async Task FullUninstall()
@@ -80,6 +87,84 @@ namespace Squirrel
await applyReleases.FullUninstall();
}
const string uninstallRegSubKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall";
public async Task<RegistryKey> CreateUninstallerRegistryEntry(string uninstallCmd, string quietSwitch)
{
var releaseContent = File.ReadAllText(Path.Combine(rootAppDirectory, "packages", "RELEASES"), Encoding.UTF8);
var releases = ReleaseEntry.ParseReleaseFile(releaseContent);
var latest = releases.OrderByDescending(x => x.Version).First();
// Download the icon and PNG => ICO it. If this doesn't work, who cares
var pkgPath = Path.Combine(rootAppDirectory, "packages", latest.Filename);
var zp = new ZipPackage(pkgPath);
var targetPng = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".png");
var targetIco = Path.Combine(rootAppDirectory, "app.ico");
var key = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Default)
.CreateSubKey(uninstallRegSubKey + "\\" + applicationName, RegistryKeyPermissionCheck.ReadWriteSubTree);
try {
var wc = new WebClient();
await wc.DownloadFileTaskAsync(zp.IconUrl, targetPng);
using (var fs = new FileStream(targetIco, FileMode.Create)) {
if (zp.IconUrl.AbsolutePath.EndsWith("ico")) {
var bytes = File.ReadAllBytes(targetPng);
fs.Write(bytes, 0, bytes.Length);
} else {
using (var bmp = (Bitmap)Image.FromFile(targetPng))
using (var ico = Icon.FromHandle(bmp.GetHicon())) {
ico.Save(fs);
}
}
key.SetValue("DisplayIcon", targetIco, RegistryValueKind.String);
}
} catch(Exception ex) {
this.Log().InfoException("Couldn't write uninstall icon, don't care", ex);
} finally {
File.Delete(targetPng);
}
var stringsToWrite = new[] {
new { Key = "DisplayName", Value = zp.Description ?? zp.Summary },
new { Key = "DisplayVersion", Value = zp.Version.ToString() },
new { Key = "InstallDate", Value = DateTime.Now.ToString("yyyymmdd") },
new { Key = "InstallLocation", Value = rootAppDirectory },
new { Key = "Publisher", Value = zp.Authors.First() },
new { Key = "QuietUninstallString", Value = String.Format("{0} {1}", uninstallCmd, quietSwitch) },
new { Key = "UninstallString", Value = uninstallCmd },
};
var dwordsToWrite = new[] {
new { Key = "EstimatedSize", Value = (int)((new FileInfo(pkgPath)).Length / 1024) },
new { Key = "NoModify", Value = 1 },
new { Key = "NoRepair", Value = 1 },
new { Key = "Language", Value = 0x0409 },
};
foreach (var kvp in stringsToWrite) {
key.SetValue(kvp.Key, kvp.Value, RegistryValueKind.String);
}
foreach (var kvp in dwordsToWrite) {
key.SetValue(kvp.Key, kvp.Value, RegistryValueKind.DWord);
}
return key;
}
public void RemoveUninstallerRegistryEntry()
{
var key = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Default)
.OpenSubKey(uninstallRegSubKey, true);
key.DeleteSubKeyTree(applicationName);
}
public string RootAppDirectory {
get { return rootAppDirectory; }
}
public void Dispose()
{
var disp = Interlocked.Exchange(ref updateLock, null);

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,181 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Mono.Options;
using Squirrel;
namespace Update
namespace Squirrel.Update
{
enum UpdateAction {
Unset = 0, Install, Uninstall, Download, Update,
}
class Program
{
static void Main(string[] args)
static OptionSet opts;
static int Main(string[] args)
{
if (args.Any(x => x.StartsWith("/squirrel", StringComparison.OrdinalIgnoreCase))) {
// NB: We're marked as Squirrel-aware, but we don't want to do
// anything in response to these events
return 0;
}
bool silentInstall = false;
var updateAction = default(UpdateAction);
string target = default(string);
opts = new OptionSet() {
"Usage: Update.exe command [OPTS]",
"Manages Squirrel packages",
"",
"Commands",
{ "install=", "Install the app whose package is in the specified directory", v => { updateAction = UpdateAction.Install; target = v; } },
{ "uninstall", "Uninstall the app the same dir as Update.exe", v => updateAction = UpdateAction.Uninstall},
{ "download=", "Download the releases specified by the URL and write new results to stdout as JSON", v => { updateAction = UpdateAction.Download; target = v; } },
{ "update=", "Update the application to the latest remote version specified by URL", v => { updateAction = UpdateAction.Update; target = v; } },
"",
"Options:",
{ "h|?|help", "Display Help and exit", _ => ShowHelp() },
{ "s|silent", "Silent install", _ => silentInstall = true},
};
opts.Parse(args);
if (updateAction == UpdateAction.Unset) {
ShowHelp();
}
switch (updateAction) {
case UpdateAction.Install:
Install(silentInstall, Path.GetFullPath(target)).Wait();
break;
case UpdateAction.Uninstall:
Uninstall().Wait();
break;
case UpdateAction.Download:
Console.WriteLine(Download(target).Result);
break;
case UpdateAction.Update:
Update(target).Wait();
break;
}
Console.WriteLine("\n");
return 0;
}
public static async Task Install(bool silentInstall, string sourceDirectory = null)
{
sourceDirectory = sourceDirectory ?? Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
var releasesPath = Path.Combine(sourceDirectory, "RELEASES");
if (!File.Exists(releasesPath)) {
var nupkgs = (new DirectoryInfo(sourceDirectory)).GetFiles()
.Where(x => x.Name.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))
.Select(x => ReleaseEntry.GenerateFromFile(x.FullName));
ReleaseEntry.WriteReleaseFile(nupkgs, releasesPath);
}
var ourAppName = ReleaseEntry.ParseReleaseFile(File.ReadAllText(releasesPath, Encoding.UTF8))
.First().PackageName;
using (var mgr = new UpdateManager(sourceDirectory, ourAppName, FrameworkVersion.Net45)) {
await mgr.FullInstall(silentInstall);
var updateTarget = Path.Combine(mgr.RootAppDirectory, "Update.exe");
File.Copy(Assembly.GetExecutingAssembly().Location, updateTarget, true);
await mgr.CreateUninstallerRegistryEntry(String.Format("{0} --uninstall", updateTarget), "-s");
}
}
public static async Task Update(string updateUrl, string appName = null)
{
appName = appName ?? getAppNameFromDirectory();
using (var mgr = new UpdateManager(updateUrl, appName, FrameworkVersion.Net45)) {
var updateInfo = await mgr.CheckForUpdate(progress: x => Console.WriteLine(x / 3));
await mgr.DownloadReleases(updateInfo.ReleasesToApply, x => Console.WriteLine(33 + x / 3));
await mgr.ApplyReleases(updateInfo, x => Console.WriteLine(66 + x / 3));
}
// TODO: Update our installer entry
}
public static async Task<string> Download(string updateUrl, string appName = null)
{
ensureConsole();
appName = appName ?? getAppNameFromDirectory();
using (var mgr = new UpdateManager(updateUrl, appName, FrameworkVersion.Net45)) {
var updateInfo = await mgr.CheckForUpdate(progress: x => Console.WriteLine(x / 3));
await mgr.DownloadReleases(updateInfo.ReleasesToApply, x => Console.WriteLine(33 + x / 3));
return SimpleJson.SerializeObject(updateInfo);
}
}
public static async Task Uninstall(string appName = null)
{
appName = appName ?? getAppNameFromDirectory();
using (var mgr = new UpdateManager("", appName, FrameworkVersion.Net45)) {
await mgr.FullUninstall();
mgr.RemoveUninstallerRegistryEntry();
}
}
public static void ShowHelp()
{
ensureConsole();
opts.WriteOptionDescriptions(Console.Out);
Environment.Exit(1);
}
static string getAppNameFromDirectory(string path = null)
{
path = path ?? Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
return (new DirectoryInfo(path)).Name;
}
static int consoleCreated = 0;
static void ensureConsole()
{
if (Interlocked.CompareExchange(ref consoleCreated, 1, 0) == 1) return;
if (!NativeMethods.AttachConsole(-1)) {
NativeMethods.AllocConsole();
}
NativeMethods.GetStdHandle(StandardHandles.STD_ERROR_HANDLE);
NativeMethods.GetStdHandle(StandardHandles.STD_OUTPUT_HANDLE);
}
}
}
enum StandardHandles : int {
STD_INPUT_HANDLE = -10,
STD_OUTPUT_HANDLE = -11,
STD_ERROR_HANDLE = -12,
}
static class NativeMethods
{
[DllImport("kernel32.dll", EntryPoint = "GetStdHandle")]
public static extern IntPtr GetStdHandle(StandardHandles nStdHandle);
[DllImport("kernel32.dll", EntryPoint = "AllocConsole")]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool AllocConsole();
[DllImport("kernel32.dll")]
public static extern bool AttachConsole(int pid);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,9 @@
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{1EEBACBC-6982-4696-BD4E-899ED0AC6CD2}</ProjectGuid>
<OutputType>Exe</OutputType>
<OutputType>WinExe</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>Update</RootNamespace>
<RootNamespace>Squirrel.Update</RootNamespace>
<AssemblyName>Update</AssemblyName>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
@@ -31,7 +31,32 @@
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<StartupObject />
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.Web.XmlTransform">
<HintPath>..\..\packages\Microsoft.Web.Xdt.2.1.1\lib\net40\Microsoft.Web.XmlTransform.dll</HintPath>
</Reference>
<Reference Include="Mono.Cecil">
<HintPath>..\..\packages\Mono.Cecil.0.9.5.4\lib\net40\Mono.Cecil.dll</HintPath>
</Reference>
<Reference Include="Mono.Cecil.Mdb">
<HintPath>..\..\packages\Mono.Cecil.0.9.5.4\lib\net40\Mono.Cecil.Mdb.dll</HintPath>
</Reference>
<Reference Include="Mono.Cecil.Pdb">
<HintPath>..\..\packages\Mono.Cecil.0.9.5.4\lib\net40\Mono.Cecil.Pdb.dll</HintPath>
</Reference>
<Reference Include="Mono.Cecil.Rocks">
<HintPath>..\..\packages\Mono.Cecil.0.9.5.4\lib\net40\Mono.Cecil.Rocks.dll</HintPath>
</Reference>
<Reference Include="NuGet.Core, Version=2.8.50506.491, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<HintPath>..\..\packages\NuGet.Core.2.8.2\lib\net40-Client\NuGet.Core.dll</HintPath>
</Reference>
<Reference Include="Splat">
<HintPath>..\..\packages\Splat.1.4.0\lib\Net45\Splat.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
@@ -41,13 +66,28 @@
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Mono.Options\Options.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="SimpleJson\SimpleJson.cs" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Squirrel\Squirrel.csproj">
<Project>{1436e22a-fe3c-4d68-9a85-9e74df2e6a92}</Project>
<Name>Squirrel</Name>
</ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PostBuildEvent>cd "$(TargetPath)"
"$(SolutionDir)\packages\ILRepack.1.25.0\tools\ILRepack.exe" /internalize /out:$(TargetFileName).tmp $(TargetFileName) Ionic.Zip.dll Microsoft.Web.XmlTransform.dll Mono.Cecil.dll NuGet.Core.dll Splat.dll Squirrel.dll
del "$(TargetFileName)"
ren "$(TargetFileName).tmp" "$(TargetFileName)"</PostBuildEvent>
</PropertyGroup>
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
Other similar extension points exist, see Microsoft.Common.targets.
<Target Name="BeforeBuild">

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Web.Xdt" version="2.1.1" targetFramework="net45" />
<package id="Mono.Cecil" version="0.9.5.4" targetFramework="net45" />
<package id="Mono.Options" version="1.1" targetFramework="net45" />
<package id="NuGet.Core" version="2.8.2" targetFramework="net45" />
<package id="SimpleJson" version="0.38.0" targetFramework="net45" />
<package id="Splat" version="1.4.0" targetFramework="net45" />
</packages>

View File

@@ -205,7 +205,7 @@ namespace Squirrel.Tests
var progress = new List<int>();
await fixture.ApplyReleases(updateInfo, progress.Add);
await fixture.ApplyReleases(updateInfo, false, progress.Add);
this.Log().Info("Progress: [{0}]", String.Join(",", progress));
progress
@@ -254,7 +254,7 @@ namespace Squirrel.Tests
updateInfo.ReleasesToApply.Contains(latestFullEntry).ShouldBeTrue();
var progress = new List<int>();
await fixture.ApplyReleases(updateInfo, progress.Add);
await fixture.ApplyReleases(updateInfo, false, progress.Add);
this.Log().Info("Progress: [{0}]", String.Join(",", progress));
progress
@@ -303,7 +303,7 @@ namespace Squirrel.Tests
updateInfo.ReleasesToApply.Contains(latestFullEntry).ShouldBeTrue();
var progress = new List<int>();
await fixture.ApplyReleases(updateInfo, progress.Add);
await fixture.ApplyReleases(updateInfo, false, progress.Add);
this.Log().Info("Progress: [{0}]", String.Join(",", progress));
progress
@@ -356,7 +356,7 @@ namespace Squirrel.Tests
var progress = new List<int>();
await fixture.ApplyReleases(updateInfo, progress.Add);
await fixture.ApplyReleases(updateInfo, false, progress.Add);
this.Log().Info("Progress: [{0}]", String.Join(",", progress));
progress