Refactor channels, deployment repositories

This commit is contained in:
Caelan Sayler
2024-01-03 16:24:41 +00:00
parent 5f3f3a1d90
commit 99f66c581a
23 changed files with 590 additions and 644 deletions

View File

@@ -1,12 +1,12 @@
using Microsoft.Extensions.Logging;
using Octokit;
using Velopack.Packaging;
using Velopack.Sources;
namespace Velopack.Deployment;
public class GitHubOptions
public class GitHubOptions : RepositoryOptions
{
public DirectoryInfo ReleaseDir { get; set; }
public string RepoUrl { get; set; }
public string Token { get; set; }
@@ -15,7 +15,6 @@ public class GitHubOptions
public class GitHubDownloadOptions : GitHubOptions
{
public bool Pre { get; set; }
}
public class GitHubUploadOptions : GitHubOptions
@@ -23,66 +22,21 @@ public class GitHubUploadOptions : GitHubOptions
public bool Publish { get; set; }
public string ReleaseName { get; set; }
}
public class GitHubRepository
public class GitHubRepository : SourceRepository<GitHubDownloadOptions, GithubSource>, IRepositoryCanUpload<GitHubUploadOptions>
{
private readonly ILogger _log;
public GitHubRepository(ILogger logger)
public GitHubRepository(ILogger logger) : base(logger)
{
_log = logger;
}
public async Task DownloadRecentPackages(GitHubDownloadOptions options)
public override GithubSource CreateSource(GitHubDownloadOptions options)
{
var releaseDirectoryInfo = options.ReleaseDir;
if (String.IsNullOrWhiteSpace(options.Token))
_log.Warn("No GitHub access token provided. Unauthenticated requests will be limited to 60 per hour.");
_log.Info("Fetching RELEASES...");
var source = new GithubSource(options.RepoUrl, options.Token, options.Pre);
var latestReleaseEntries = await source.GetReleaseFeed();
if (latestReleaseEntries == null || latestReleaseEntries.Length == 0) {
_log.Warn("No github release or assets found.");
return;
}
_log.Info($"Found {latestReleaseEntries.Length} assets in latest RELEASES file.");
var releasesToDownload = latestReleaseEntries
.Where(x => !x.IsDelta)
.OrderByDescending(x => x.Version)
.Take(1)
.Select(x => new {
Obj = x,
LocalPath = Path.Combine(releaseDirectoryInfo.FullName, x.OriginalFilename),
Filename = x.OriginalFilename,
});
foreach (var entry in releasesToDownload) {
if (File.Exists(entry.LocalPath)) {
_log.Warn($"File '{entry.Filename}' exists on disk, skipping download.");
continue;
}
_log.Info($"Downloading {entry.Filename}...");
await source.DownloadReleaseEntry(entry.Obj, entry.LocalPath, (p) => { });
}
ReleaseEntry.BuildReleasesFile(releaseDirectoryInfo.FullName);
_log.Info("Done.");
return new GithubSource(options.RepoUrl, options.Token, options.Pre, options.Channel, logger: Log);
}
public async Task UploadMissingPackages(GitHubUploadOptions options)
public async Task UploadMissingAssetsAsync(GitHubUploadOptions options)
{
if (String.IsNullOrWhiteSpace(options.Token))
throw new InvalidOperationException("Must provide access token to create a GitHub release.");
var releaseDirectoryInfo = options.ReleaseDir;
var repoUri = new Uri(options.RepoUrl);
var repoParts = repoUri.AbsolutePath.Trim('/').Split('/');
if (repoParts.Length != 2)
@@ -91,85 +45,48 @@ public class GitHubRepository
var repoOwner = repoParts[0];
var repoName = repoParts[1];
var helper = new ReleaseEntryHelper(options.ReleaseDir.FullName, Log);
var assets = helper.GetUploadAssets(options.Channel, ReleaseEntryHelper.AssetsMode.OnlyLatest);
var latest = helper.GetLatestFullRelease(options.Channel);
var semVer = latest.Version;
Log.Info($"Preparing to upload {assets.Files.Count} assets to GitHub");
var client = new GitHubClient(new ProductHeaderValue("Velopack")) {
Credentials = new Credentials(options.Token)
};
var releasesPath = Path.Combine(releaseDirectoryInfo.FullName, "RELEASES");
if (!File.Exists(releasesPath))
ReleaseEntry.BuildReleasesFile(releaseDirectoryInfo.FullName);
var releases = ReleaseEntry.ParseReleaseFile(File.ReadAllText(releasesPath)).ToArray();
if (releases.Length == 0)
throw new Exception("There are no nupkg's in the releases directory to upload");
var ver = Enumerable.MaxBy(releases, x => x.Version);
if (ver == null)
throw new Exception("There are no nupkg's in the releases directory to upload");
var semVer = ver.Version;
_log.Info($"Preparing to upload latest local release to GitHub");
var newReleaseReq = new NewRelease(semVer.ToString()) {
//Body = ver.GetReleaseNotes(releaseDirectoryInfo.FullName, ReleaseNotesFormat.Markdown),
Draft = true,
Prerelease = semVer.HasMetadata || semVer.IsPrerelease,
Name = string.IsNullOrWhiteSpace(options.ReleaseName)
? semVer.ToString()
: options.ReleaseName,
Name = string.IsNullOrWhiteSpace(options.ReleaseName) ? semVer.ToString() : options.ReleaseName,
};
_log.Info($"Creating draft release titled '{semVer.ToString()}'");
Log.Info($"Creating draft release titled '{newReleaseReq.Name}'");
var existingReleases = await client.Repository.Release.GetAll(repoOwner, repoName);
if (existingReleases.Any(r => r.TagName == semVer.ToString())) {
throw new Exception($"There is already an existing release tagged '{semVer}'. Please delete this release or choose a new version number.");
throw new Exception($"There is already an existing release tagged '{semVer}'. Please delete this release or provide a new version number / release name.");
}
// create github release
var release = await client.Repository.Release.Create(repoOwner, repoName, newReleaseReq);
// locate files to upload
var files = releaseDirectoryInfo.GetFiles("*", SearchOption.TopDirectoryOnly);
var msiFile = files.SingleOrDefault(f => f.FullName.EndsWith(".msi", StringComparison.InvariantCultureIgnoreCase));
var setupFile = files.Where(f => f.FullName.EndsWith("Setup.exe", StringComparison.InvariantCultureIgnoreCase))
.ContextualSingle("release directory", "Setup.exe file");
// upload all assets (incl packages)
foreach (var a in assets.Files) {
await RetryAsync(() => UploadFileAsAsset(client, release, a.FullName), $"Uploading asset '{a.Name}'..");
}
var releasesToUpload = releases.Where(x => x.Version == semVer).ToArray();
MemoryStream releasesFileToUpload = new MemoryStream();
ReleaseEntry.WriteReleaseFile(releasesToUpload, releasesFileToUpload);
ReleaseEntry.WriteReleaseFile(assets.Releases, releasesFileToUpload);
var releasesBytes = releasesFileToUpload.ToArray();
// upload nupkg's
foreach (var r in releasesToUpload) {
var path = Path.Combine(releaseDirectoryInfo.FullName, r.OriginalFilename);
await UploadFileAsAsset(client, release, path);
}
// other files
await UploadFileAsAsset(client, release, setupFile.FullName);
if (msiFile != null) await UploadFileAsAsset(client, release, msiFile.FullName);
// RELEASES
_log.Info($"Uploading RELEASES");
var data = new ReleaseAssetUpload("RELEASES", "application/octet-stream", new MemoryStream(releasesBytes), TimeSpan.FromMinutes(1));
var data = new ReleaseAssetUpload(assets.ReleasesFileName, "application/octet-stream", new MemoryStream(releasesBytes), TimeSpan.FromMinutes(1));
await client.Repository.Release.UploadAsset(release, data, CancellationToken.None);
_log.Info($"Done creating draft GitHub release.");
// convert draft to full release
if (options.Publish) {
_log.Info("Converting draft to full published release.");
var upd = release.ToUpdate();
upd.Draft = false;
release = await client.Repository.Release.Edit(repoOwner, repoName, release.Id, upd);
}
_log.Info("Release URL: " + release.HtmlUrl);
}
private async Task UploadFileAsAsset(GitHubClient client, Release release, string filePath)
{
_log.Info($"Uploading asset '{Path.GetFileName(filePath)}'");
using var stream = File.OpenRead(filePath);
var data = new ReleaseAssetUpload(Path.GetFileName(filePath), "application/octet-stream", stream, TimeSpan.FromMinutes(30));
await client.Repository.Release.UploadAsset(release, data, CancellationToken.None);

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.Logging;
using Velopack.Sources;
namespace Velopack.Deployment;
public class HttpDownloadOptions : RepositoryOptions
{
public string Url { get; set; }
}
public class HttpRepository : SourceRepository<HttpDownloadOptions, SimpleWebSource>
{
public HttpRepository(ILogger logger)
: base(logger)
{ }
public override SimpleWebSource CreateSource(HttpDownloadOptions options)
{
return new SimpleWebSource(options.Url, options.Channel, logger: Log);
}
}

View File

@@ -5,13 +5,13 @@ using Amazon;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using Velopack.Packaging;
using Velopack.Sources;
namespace Velopack.Deployment;
public class S3Options
public class S3DownloadOptions : RepositoryOptions
{
public DirectoryInfo ReleaseDir { get; set; }
public string KeyId { get; set; }
public string Secret { get; set; }
@@ -23,27 +23,86 @@ public class S3Options
public string Endpoint { get; set; }
public string Bucket { get; set; }
public string PathPrefix { get; set; }
}
public class S3UploadOptions : S3Options
public class S3UploadOptions : S3DownloadOptions
{
public bool Overwrite { get; set; }
public int KeepMaxReleases { get; set; }
}
public class S3Repository
public class S3Repository : DownRepository<S3DownloadOptions>, IRepositoryCanUpload<S3UploadOptions>
{
private readonly ILogger Log;
public S3Repository(ILogger logger)
public S3Repository(ILogger logger) : base(logger)
{
Log = logger;
}
private static AmazonS3Client GetS3Client(S3Options options)
public async Task UploadMissingAssetsAsync(S3UploadOptions options)
{
var assets = new ReleaseEntryHelper(options.ReleaseDir.FullName, Log)
.GetUploadAssets(options.Channel, ReleaseEntryHelper.AssetsMode.AllPackages);
var releasesName = SourceBase.GetReleasesFileNameImpl(options.Channel);
var remoteReleases = await GetReleasesAsync(options);
var client = GetS3Client(options);
Log.Info($"There are {remoteReleases.Length} assets in remote RELEASES file.");
var matchCount = assets.Releases.Count(r => remoteReleases.Any(remote => remote.OriginalFilename.Equals(r.OriginalFilename)));
Log.Info($"There are {assets.Releases} local releases ({assets.Releases.Count - matchCount} new, {matchCount} matching a remote filename).");
// merge local release entries with remote ones
// will preserve the local entries because they appear first
var releaseEntries = assets.Releases
.Concat(remoteReleases)
.DistinctBy(r => r.OriginalFilename)
.OrderBy(k => k.Version)
.ThenBy(k => !k.IsDelta)
.ToArray();
Log.Info($"{releaseEntries.Length} merged releases.");
foreach (var asset in assets.Files) {
await UploadFile(client, options.Bucket, asset.Name, asset, options.Overwrite);
}
using var _1 = Utility.GetTempFileName(out var tmpReleases);
ReleaseEntry.WriteReleaseFile(releaseEntries, tmpReleases);
await UploadFile(client, options.Bucket, releasesName, new FileInfo(tmpReleases), true);
Log.Info("Done.");
}
protected override async Task<ReleaseEntry[]> GetReleasesAsync(S3DownloadOptions options)
{
var releasesName = SourceBase.GetReleasesFileNameImpl(options.Channel);
var client = GetS3Client(options);
var ms = new MemoryStream();
try {
await RetryAsync(async () => {
using (var obj = await client.GetObjectAsync(options.Bucket, releasesName))
using (var stream = obj.ResponseStream) {
await stream.CopyToAsync(ms);
}
}, $"Fetching {releasesName}...");
} catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) {
return new ReleaseEntry[0];
}
return ReleaseEntry.ParseReleaseFile(Encoding.UTF8.GetString(ms.ToArray())).ToArray();
}
protected override async Task SaveEntryToFileAsync(S3DownloadOptions options, ReleaseEntry entry, string filePath)
{
var client = GetS3Client(options);
await RetryAsync(async () => {
using (var obj = await client.GetObjectAsync(options.Bucket, entry.OriginalFilename)) {
await obj.WriteResponseStreamToFileAsync(filePath, false, CancellationToken.None);
}
}, $"Downloading {entry.OriginalFilename}...");
}
private static AmazonS3Client GetS3Client(S3DownloadOptions options)
{
if (options.Region != null) {
var r = RegionEndpoint.GetBySystemName(options.Region);
@@ -59,273 +118,46 @@ public class S3Repository
}
}
private static string GetPrefix(S3Options options)
private async Task UploadFile(AmazonS3Client client, string bucket, string key, FileInfo f, bool overwriteRemote)
{
var prefix = options.PathPrefix?.Replace('\\', '/') ?? "";
if (!String.IsNullOrWhiteSpace(prefix) && !prefix.EndsWith("/")) prefix += "/";
return prefix;
}
public async Task DownloadRecentPackages(S3Options options)
{
var _client = GetS3Client(options);
var _prefix = GetPrefix(options);
var releasesDir = options.ReleaseDir;
var releasesPath = Path.Combine(releasesDir.FullName, "RELEASES");
Log.Info($"Downloading latest release to '{releasesDir.FullName}' from S3 bucket '{options.Bucket}'"
+ (String.IsNullOrWhiteSpace(_prefix) ? "" : " with prefix '" + _prefix + "'"));
string deleteOldVersionId = null;
// try to detect an existing remote file of the same name
try {
Log.Info("Downloading RELEASES");
using (var obj = await _client.GetObjectAsync(options.Bucket, _prefix + "RELEASES"))
await obj.WriteResponseStreamToFileAsync(releasesPath, false, CancellationToken.None);
} catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) {
Log.Warn("RELEASES file not found. No releases to download.");
return;
}
var metadata = await client.GetObjectMetadataAsync(bucket, key);
var md5 = GetFileMD5Checksum(f.FullName);
var stored = metadata?.ETag?.Trim().Trim('"');
var releasesToDownload = ReleaseEntry.ParseReleaseFile(File.ReadAllText(releasesPath))
.Where(x => !x.IsDelta)
.OrderByDescending(x => x.Version)
.Take(1)
.Select(x => new {
LocalPath = Path.Combine(releasesDir.FullName, x.OriginalFilename),
Filename = x.OriginalFilename,
});
foreach (var releaseToDownload in releasesToDownload) {
Log.Info("Downloading " + releaseToDownload.Filename);
using (var pkgobj = await _client.GetObjectAsync(options.Bucket, _prefix + releaseToDownload.Filename))
await pkgobj.WriteResponseStreamToFileAsync(releaseToDownload.LocalPath, false, CancellationToken.None);
}
}
public async Task UploadMissingPackages(S3UploadOptions options)
{
var _client = GetS3Client(options);
var _prefix = GetPrefix(options);
var releasesDir = options.ReleaseDir;
Log.Info($"Uploading releases from '{releasesDir.FullName}' to S3 bucket '{options.Bucket}'"
+ (String.IsNullOrWhiteSpace(_prefix) ? "" : " with prefix '" + _prefix + "'"));
// locate files to upload
var files = releasesDir.GetFiles("*", SearchOption.TopDirectoryOnly);
var msiFile = files.Where(f => f.FullName.EndsWith(".msi", StringComparison.InvariantCultureIgnoreCase)).SingleOrDefault();
var setupFile = files.Where(f => f.FullName.EndsWith("Setup.exe", StringComparison.InvariantCultureIgnoreCase))
.ContextualSingle("release directory", "Setup.exe file");
var releasesFile = files.Where(f => f.Name.Equals("RELEASES", StringComparison.InvariantCultureIgnoreCase))
.ContextualSingle("release directory", "RELEASES file");
var nupkgFiles = files.Where(f => f.FullName.EndsWith(".nupkg", StringComparison.InvariantCultureIgnoreCase)).ToArray();
// we will merge the remote RELEASES file with the local one
string remoteReleasesContent = null;
try {
Log.Info("Downloading remote RELEASES file");
using (var obj = await _client.GetObjectAsync(options.Bucket, _prefix + "RELEASES"))
using (var sr = new StreamReader(obj.ResponseStream, Encoding.UTF8, true))
remoteReleasesContent = await sr.ReadToEndAsync();
Log.Info("Merging remote and local RELEASES files");
} catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound) {
Log.Warn("No remote RELEASES found.");
}
var localReleases = ReleaseEntry.ParseReleaseFile(File.ReadAllText(releasesFile.FullName));
var remoteReleases = ReleaseEntry.ParseReleaseFile(remoteReleasesContent);
// apply retention policy. count '-full' versions only, then also remove corresponding delta packages
var releaseEntries = localReleases
.Concat(remoteReleases)
.DistinctBy(r => r.OriginalFilename) // will preserve the local entries because they appear first
.OrderBy(k => k.Version)
.ThenBy(k => !k.IsDelta)
.ToArray();
if (releaseEntries.Length == 0) {
Log.Warn("No releases found.");
return;
}
if (!releaseEntries.All(f => f.PackageId == releaseEntries.First().PackageId)) {
throw new Exception("There are mix-matched package Id's in local/remote RELEASES file. " +
"Please fix the release files manually so there is only one consistent package Id present.");
}
var fullCount = releaseEntries.Where(r => !r.IsDelta).Count();
if (options.KeepMaxReleases > 0 && fullCount > options.KeepMaxReleases) {
Log.Info($"Retention Policy: {fullCount - options.KeepMaxReleases} releases will be removed from RELEASES file.");
var fullReleases = releaseEntries
.OrderByDescending(k => k.Version)
.Where(k => !k.IsDelta)
.Take(options.KeepMaxReleases)
.ToArray();
var deltaReleases = releaseEntries
.Where(k => k.IsDelta)
.Where(k => fullReleases.Any(f => f.Version == k.Version))
.Where(k => k.Version != fullReleases.Last().Version) // ignore delta packages for the oldest full package
.ToArray();
Log.Info($"Total number of packages in remote after retention: {fullReleases.Length} full, {deltaReleases.Length} delta.");
fullCount = fullReleases.Length;
ReleaseEntry.WriteReleaseFile(fullReleases.Concat(deltaReleases), releasesFile.FullName);
} else {
Log.Info($"There are currently {fullCount} full releases in RELEASES file.");
ReleaseEntry.WriteReleaseFile(releaseEntries, releasesFile.FullName);
}
async Task UploadFile(FileInfo f, bool overwriteRemote)
{
string key = _prefix + f.Name;
string deleteOldVersionId = null;
// try to detect an existing remote file of the same name
try {
var metadata = await _client.GetObjectMetadataAsync(options.Bucket, key);
var md5 = GetFileMD5Checksum(f.FullName);
var stored = metadata?.ETag?.Trim().Trim('"');
if (stored != null) {
if (stored.Equals(md5, StringComparison.InvariantCultureIgnoreCase)) {
Log.Info($"Upload file '{f.Name}' skipped (already exists in remote)");
return;
} else if (overwriteRemote) {
Log.Info($"File '{f.Name}' exists in remote, replacing...");
deleteOldVersionId = metadata.VersionId;
} else {
Log.Warn($"File '{f.Name}' exists in remote and checksum does not match local file. Use 'overwrite' argument to replace remote file.");
return;
}
if (stored != null) {
if (stored.Equals(md5, StringComparison.InvariantCultureIgnoreCase)) {
Log.Info($"Upload file '{f.Name}' skipped (already exists in remote)");
return;
} else if (overwriteRemote) {
Log.Info($"File '{f.Name}' exists in remote, replacing...");
deleteOldVersionId = metadata.VersionId;
} else {
Log.Warn($"File '{f.Name}' exists in remote and checksum does not match local file. Use 'overwrite' argument to replace remote file.");
return;
}
} catch {
// don't care if this check fails. worst case, we end up re-uploading a file that
// already exists. storage providers should prefer the newer file of the same name.
}
var req = new PutObjectRequest {
BucketName = options.Bucket,
FilePath = f.FullName,
Key = key,
};
await RetryAsync(() => _client.PutObjectAsync(req), "Uploading " + f.Name);
if (deleteOldVersionId != null) {
await RetryAsync(() => _client.DeleteObjectAsync(options.Bucket, key, deleteOldVersionId),
"Removing old version of " + f.Name,
throwIfFail: false);
}
} catch {
// don't care if this check fails. worst case, we end up re-uploading a file that
// already exists. storage providers should prefer the newer file of the same name.
}
// we need to upload things in a certain order. If we upload 'RELEASES' first, for example, a client
// might try to request a nupkg that does not yet exist.
// upload nupkg's first
foreach (var f in nupkgFiles) {
if (!releaseEntries.Any(r => r.OriginalFilename.Equals(f.Name, StringComparison.InvariantCultureIgnoreCase))) {
Log.Warn($"Upload file '{f.Name}' skipped (not in RELEASES file)");
continue;
}
await UploadFile(f, options.Overwrite);
}
// next upload setup files
await UploadFile(setupFile, true);
if (msiFile != null) await UploadFile(msiFile, true);
// upload RELEASES
await UploadFile(releasesFile, true);
// ignore dead package cleanup if there is no retention policy
if (options.KeepMaxReleases > 0) {
// remove any dead packages (not in RELEASES) as they are undiscoverable anyway
Log.Info("Searching for remote dead packages (not in RELEASES file)");
var objects = await ListBucketContentsAsync(_client, options.Bucket, _prefix).ToArrayAsync();
var deadObjectQuery =
from o in objects
let key = o.Key
where key.EndsWith(".nupkg", StringComparison.InvariantCultureIgnoreCase)
where key.StartsWith(_prefix, StringComparison.InvariantCultureIgnoreCase)
let fileName = key.Substring(_prefix.Length)
where !fileName.Contains('/') // filters out objects in folders if _prefix is empty
where !releaseEntries.Any(r => r.OriginalFilename.Equals(fileName, StringComparison.InvariantCultureIgnoreCase))
orderby o.LastModified ascending
select new { key, fileName, versionId = o.VersionId };
var deadObj = deadObjectQuery.ToArray();
Log.Info($"Found {deadObj.Length} dead packages.");
foreach (var s3obj in deadObj) {
var req = new DeleteObjectRequest { BucketName = options.Bucket, Key = s3obj.key, VersionId = s3obj.versionId };
await RetryAsync(() => _client.DeleteObjectAsync(req), "Deleting dead package: " + s3obj, throwIfFail: false);
}
}
Log.Info("Done");
var regionEndpoint = RegionEndpoint.GetBySystemName(options.Region);
#pragma warning disable CS0618 // Type or member is obsolete
var endpointHost = options.Endpoint ?? regionEndpoint.GetEndpointForService("s3").Hostname;
#pragma warning restore CS0618 // Type or member is obsolete
if (Regex.IsMatch(endpointHost, @"^https?:\/\/", RegexOptions.IgnoreCase)) {
endpointHost = new Uri(endpointHost, UriKind.Absolute).Host;
}
var baseurl = $"https://{options.Bucket}.{endpointHost}/{_prefix}";
Log.Info($"Bucket URL: {baseurl}");
Log.Info($"Setup URL: {baseurl}{setupFile.Name}");
}
private static async IAsyncEnumerable<S3ObjectVersion> ListBucketContentsAsync(IAmazonS3 client, string bucketName, string prefix)
{
var request = new ListVersionsRequest {
BucketName = bucketName,
MaxKeys = 100,
Prefix = prefix,
var req = new PutObjectRequest {
BucketName = bucket,
FilePath = f.FullName,
Key = key,
};
ListVersionsResponse response;
do {
response = await client.ListVersionsAsync(request);
foreach (var obj in response.Versions) {
yield return obj;
}
await RetryAsync(() => client.PutObjectAsync(req), "Uploading " + f.Name);
// If the response is truncated, set the request ContinuationToken
// from the NextContinuationToken property of the response.
request.KeyMarker = response.NextKeyMarker;
request.VersionIdMarker = response.NextVersionIdMarker;
} while (response.IsTruncated);
}
private async Task RetryAsync(Func<Task> block, string message, bool throwIfFail = true, bool showMessageFirst = true)
{
int ctry = 0;
while (true) {
if (deleteOldVersionId != null) {
try {
if (showMessageFirst || ctry > 0)
Log.Info((ctry > 0 ? $"(retry {ctry}) " : "") + message);
await block().ConfigureAwait(false);
return;
} catch (Exception ex) {
if (ctry++ > 2) {
if (throwIfFail) {
throw;
} else {
Log.Error("Error: " + ex.Message + ", will not try again.");
return;
}
}
Log.Error($"Error: {ex.Message}, retrying in 1 second.");
await Task.Delay(1000).ConfigureAwait(false);
}
await RetryAsync(() => client.DeleteObjectAsync(bucket, key, deleteOldVersionId),
"Removing old version of " + f.Name);
} catch { }
}
}

View File

@@ -1,85 +0,0 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
namespace Velopack.Deployment;
public class HttpDownloadOptions
{
public DirectoryInfo ReleaseDir { get; set; }
public string Url { get; set; }
}
public class SimpleWebRepository
{
private readonly ILogger _logger;
public SimpleWebRepository(ILogger logger)
{
_logger = logger;
}
public async Task DownloadRecentPackages(HttpDownloadOptions options)
{
var uri = new Uri(options.Url);
var releasesDir = options.ReleaseDir;
var releasesUri = Utility.AppendPathToUri(uri, "RELEASES");
var releasesIndex = await retryAsync(3, () => downloadReleasesIndex(releasesUri));
File.WriteAllText(Path.Combine(releasesDir.FullName, "RELEASES"), releasesIndex);
var releasesToDownload = ReleaseEntry.ParseReleaseFile(releasesIndex)
.Where(x => !x.IsDelta)
.OrderByDescending(x => x.Version)
.Take(1)
.Select(x => new {
LocalPath = Path.Combine(releasesDir.FullName, x.OriginalFilename),
RemoteUrl = new Uri(Utility.EnsureTrailingSlash(uri), x.BaseUrl + x.OriginalFilename + x.Query)
});
foreach (var releaseToDownload in releasesToDownload) {
await retryAsync(3, () => downloadRelease(releaseToDownload.LocalPath, releaseToDownload.RemoteUrl));
}
}
async Task<string> downloadReleasesIndex(Uri uri)
{
_logger.Info($"Trying to download RELEASES index from {uri}");
var userAgent = new System.Net.Http.Headers.ProductInfoHeaderValue("Velopack", Assembly.GetExecutingAssembly().GetName().Version.ToString());
using (HttpClient client = new HttpClient()) {
client.DefaultRequestHeaders.UserAgent.Add(userAgent);
return await client.GetStringAsync(uri);
}
}
async Task downloadRelease(string localPath, Uri remoteUrl)
{
if (File.Exists(localPath)) {
File.Delete(localPath);
}
_logger.Info($"Downloading release from {remoteUrl}");
var wc = Utility.CreateDefaultDownloader();
await wc.DownloadFile(remoteUrl.ToString(), localPath, null);
}
static async Task<T> retryAsync<T>(int count, Func<Task<T>> block)
{
int retryCount = count;
retry:
try {
return await block();
} catch (Exception) {
retryCount--;
if (retryCount >= 0) goto retry;
throw;
}
}
static async Task retryAsync(int count, Func<Task> block)
{
await retryAsync(count, async () => { await block(); return false; });
}
}

View File

@@ -6,10 +6,6 @@
<NoWarn>$(NoWarn);CA2007;CS8002</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Velopack\Velopack.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.305" />
<PackageReference Include="Octokit" Version="9.0.0" />
@@ -17,4 +13,8 @@
<PackageReference Include="NuGet.Commands" Version="6.8.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Velopack.Packaging\Velopack.Packaging.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Velopack.Sources;
using Velopack.Packaging;
namespace Velopack.Deployment;
public class RepositoryOptions
{
public string Channel { get; set; }
public DirectoryInfo ReleaseDir { get; set; }
}
public interface IRepositoryCanUpload<TUp> where TUp : RepositoryOptions
{
Task UploadMissingAssetsAsync(TUp options);
}
public interface IRepositoryCanDownload<TDown> where TDown : RepositoryOptions
{
Task DownloadLatestFullPackageAsync(TDown options);
}
public abstract class SourceRepository<TDown, TSource> : DownRepository<TDown>
where TDown : RepositoryOptions
where TSource : IUpdateSource
{
public SourceRepository(ILogger logger)
: base(logger)
{ }
protected override Task<ReleaseEntry[]> GetReleasesAsync(TDown options)
{
var source = CreateSource(options);
return source.GetReleaseFeed();
}
protected override Task SaveEntryToFileAsync(TDown options, ReleaseEntry entry, string filePath)
{
var source = CreateSource(options);
return source.DownloadReleaseEntry(entry, filePath, (i) => { });
}
public abstract TSource CreateSource(TDown options);
}
public abstract class DownRepository<TDown> : IRepositoryCanDownload<TDown>
where TDown : RepositoryOptions
{
protected ILogger Log { get; }
public DownRepository(ILogger logger)
{
Log = logger;
}
public virtual async Task DownloadLatestFullPackageAsync(TDown options)
{
var releasesName = SourceBase.GetReleasesFileNameImpl(options.Channel);
ReleaseEntry[] releases = await RetryAsyncRet(() => GetReleasesAsync(options), $"Fetching {releasesName}...");
Log.Info($"Found {releases.Length} release in remote file");
var latest = releases.Where(r => !r.IsDelta).OrderByDescending(r => r.Version).FirstOrDefault();
if (latest == null) {
Log.Warn("No full / applicible release was found to download. Aborting.");
return;
}
var path = Path.Combine(options.ReleaseDir.FullName, latest.OriginalFilename);
var incomplete = Path.Combine(options.ReleaseDir.FullName, latest.OriginalFilename + ".incomplete");
if (File.Exists(path)) {
Log.Warn($"File '{path}' already exists on disk. Verifying checksum...");
var hash = Utility.CalculateFileSHA1(path);
if (hash == latest.SHA1) {
Log.Info("Checksum matches. Finished.");
return;
} else {
Log.Info($"Checksum mismatch, re-downloading...");
}
}
await RetryAsync(() => SaveEntryToFileAsync(options, latest, incomplete), $"Downloading {latest.OriginalFilename}...");
Log.Info("Verifying checksum...");
var newHash = Utility.CalculateFileSHA1(incomplete);
if (newHash != latest.SHA1) {
Log.Error($"Checksum mismatch, expected {latest.SHA1}, got {newHash}");
return;
}
File.Move(incomplete, path, true);
Log.Info("Finished.");
}
protected abstract Task<ReleaseEntry[]> GetReleasesAsync(TDown options);
protected abstract Task SaveEntryToFileAsync(TDown options, ReleaseEntry entry, string filePath);
protected async Task<T> RetryAsyncRet<T>(Func<Task<T>> block, string message, int maxRetries = 3)
{
int ctry = 0;
while (true) {
try {
Log.Info((ctry > 0 ? $"(retry {ctry}) " : "") + message);
return await block().ConfigureAwait(false);
} catch (Exception ex) {
if (ctry++ > maxRetries) {
Log.Error(ex.Message + ", will not try again.");
throw;
}
Log.Error($"{ex.Message}, retrying in 1 second.");
await Task.Delay(1000).ConfigureAwait(false);
}
}
}
protected async Task RetryAsync(Func<Task> block, string message, int maxRetries = 3)
{
int ctry = 0;
while (true) {
try {
Log.Info((ctry > 0 ? $"(retry {ctry}) " : "") + message);
await block().ConfigureAwait(false);
return;
} catch (Exception ex) {
if (ctry++ > maxRetries) {
Log.Error(ex.Message + ", will not try again.");
throw;
}
Log.Error($"{ex.Message}, retrying in 1 second.");
await Task.Delay(1000).ConfigureAwait(false);
}
}
}
}

View File

@@ -16,12 +16,15 @@ public class OsxPackCommandRunner
public void Releasify(OsxPackOptions options)
{
if (options.TargetRuntime.BaseRID != RuntimeOs.OSX)
throw new ArgumentException("Target runtime must be OSX.", nameof(options.TargetRuntime));
var releaseDir = options.ReleaseDir;
var channel = options.Channel?.ToLower() ?? "osx"; // default channel for mac packages.
var channel = options.Channel?.ToLower() ?? ReleaseEntryHelper.GetDefaultChannel(RuntimeOs.OSX);
var helper = new HelperExe(_logger);
var entryHelper = new ReleaseEntryHelper(releaseDir.FullName, _logger);
entryHelper.ValidateEntriesForPackaging(SemanticVersion.Parse(options.PackVersion), channel);
entryHelper.ValidateChannelForPackaging(SemanticVersion.Parse(options.PackVersion), channel, options.TargetRuntime);
bool deleteAppBundle = false;
string appBundlePath = options.PackDirectory;
@@ -39,6 +42,11 @@ public class OsxPackCommandRunner
var packAuthors = options.PackAuthors;
var packVersion = options.PackVersion;
var suffix = ReleaseEntryHelper.GetPkgSuffix(RuntimeOs.OSX, channel);
if (!String.IsNullOrWhiteSpace(suffix)) {
options.PackVersion += suffix;
}
_logger.Info("Adding Squirrel resources to bundle.");
var nuspecText = NugetConsole.CreateNuspec(
packId, packTitle, packAuthors, packVersion, options.ReleaseNotes, options.IncludePdb);
@@ -48,7 +56,7 @@ public class OsxPackCommandRunner
File.WriteAllText(nuspecPath, nuspecText);
File.Copy(helper.UpdateMacPath, Path.Combine(structure.MacosDirectory, "UpdateMac"), true);
var zipPath = Path.Combine(releaseDir.FullName, $"{options.PackId}-[{options.TargetRuntime.ToDisplay(RidDisplayType.NoVersion)}]-Portable.zip");
var zipPath = entryHelper.GetSuggestedPortablePath(packId, channel, options.TargetRuntime);
if (File.Exists(zipPath)) File.Delete(zipPath);
// code signing all mach-o binaries
@@ -92,7 +100,7 @@ public class OsxPackCommandRunner
// create installer package, sign and notarize
if (!options.NoPackage) {
var pkgPath = Path.Combine(releaseDir.FullName, $"{packId}-[{options.TargetRuntime.ToDisplay(RidDisplayType.NoVersion)}]-Setup.pkg");
var pkgPath = entryHelper.GetSuggestedSetupPath(packId, channel, options.TargetRuntime);
Dictionary<string, string> pkgContent = new() {
{"welcome", options.PackageWelcome },

View File

@@ -16,9 +16,6 @@ public class WindowsPackCommandRunner
public void Pack(WindowsPackOptions options)
{
if (options.EntryExecutableName == null)
options.EntryExecutableName = options.PackId + ".exe";
using (Utility.GetTempDirectory(out var tmp)) {
var nupkgPath = new NugetConsole(_logger).CreatePackageFromOptions(tmp, options);
options.Package = nupkgPath;

View File

@@ -18,11 +18,14 @@ public class WindowsReleasifyCommandRunner
public void Releasify(WindowsReleasifyOptions options)
{
if (options.TargetRuntime?.BaseRID != RuntimeOs.Windows)
throw new ArgumentException("Target runtime must be Windows.", nameof(options.TargetRuntime));
var targetDir = options.ReleaseDir.FullName;
var package = options.Package;
var backgroundGif = options.SplashImage;
var setupIcon = options.Icon;
var channel = options.Channel?.ToLower();
var channel = options.Channel?.ToLower() ?? ReleaseEntryHelper.GetDefaultChannel(RuntimeOs.Windows);
// normalize and validate that the provided frameworks are supported
IEnumerable<Runtimes.RuntimeInfo> requiredFrameworks = Enumerable.Empty<Runtimes.RuntimeInfo>();
@@ -57,7 +60,7 @@ public class WindowsReleasifyCommandRunner
var rp = new ReleasePackageBuilder(_logger, fileToProcess);
var entryHelper = new ReleaseEntryHelper(targetDir, _logger);
entryHelper.ValidateEntriesForPackaging(rp.Version, channel);
entryHelper.ValidateChannelForPackaging(rp.Version, channel, options.TargetRuntime);
rp.CreateReleasePackage(contentsPostProcessHook: (pkgPath, zpkg) => {
var nuspecPath = Directory.GetFiles(pkgPath, "*.nuspec", SearchOption.TopDirectoryOnly)
@@ -65,14 +68,15 @@ public class WindowsReleasifyCommandRunner
var libDir = Directory.GetDirectories(Path.Combine(pkgPath, "lib"))
.ContextualSingle("package", "'lib' folder");
var mainExe = Path.Combine(libDir, options.EntryExecutableName);
var mainExeName = options.EntryExecutableName ?? zpkg.Id + ".exe";
var mainExe = Path.Combine(libDir, mainExeName);
if (!File.Exists(mainExe))
throw new ArgumentException($"--exeName '{options.EntryExecutableName}' does not exist in package. Searched at: '{mainExe}'");
throw new ArgumentException($"--exeName '{mainExeName}' does not exist in package. Searched at: '{mainExe}'");
try {
var psi = new ProcessStartInfo(mainExe);
psi.AppendArgumentListSafe(new[] { "--veloapp-version" }, out var _);
var output = psi.Output(5000).GetAwaiterResult();
var output = psi.Output(3000);
if (String.IsNullOrWhiteSpace(output)) {
throw new Exception("Process exited with no output.");
}
@@ -102,7 +106,10 @@ public class WindowsReleasifyCommandRunner
"Please publish your application to a folder without ClickOnce.");
}
NuspecManifest.SetMetadata(nuspecPath, options.EntryExecutableName, requiredFrameworks.Select(r => r.Id), options.TargetRuntime);
var versionSuffix = ReleaseEntryHelper.GetPkgSuffix(RuntimeOs.Windows, options.Channel);
var versionOverride = String.IsNullOrWhiteSpace(versionSuffix)
? zpkg.Version : SemanticVersion.Parse(zpkg.Version.ToFullString() + versionSuffix);
NuspecManifest.SetMetadata(nuspecPath, options.EntryExecutableName, requiredFrameworks.Select(r => r.Id), options.TargetRuntime, versionOverride.ToFullString());
// copy Update.exe into package, so it can also be updated in both full/delta packages
// and do it before signing so that Update.exe will also be signed. It is renamed to
@@ -121,7 +128,7 @@ public class WindowsReleasifyCommandRunner
if (setupIcon != null) File.Copy(setupIcon, Path.Combine(pkgPath, "setup.ico"), true);
if (backgroundGif != null) File.Copy(backgroundGif, Path.Combine(pkgPath, "splashimage" + Path.GetExtension(backgroundGif)));
var releaseName = new ReleaseEntryName(spec.Id, spec.Version, false, options.TargetRuntime);
var releaseName = new ReleaseEntryName(spec.Id, versionOverride, false, options.TargetRuntime);
return Path.Combine(targetDir, releaseName.ToFileName());
});
@@ -141,7 +148,7 @@ public class WindowsReleasifyCommandRunner
entryHelper.SaveReleasesFiles();
var bundledzp = new ZipPackage(package);
var targetSetupExe = Path.Combine(targetDir, $"{bundledzp.Id}-[{options.TargetRuntime.ToDisplay(RidDisplayType.NoVersion)}]-Setup.exe");
var targetSetupExe = entryHelper.GetSuggestedSetupPath(bundledzp.Id, channel, options.TargetRuntime);
File.Copy(helper.SetupPath, targetSetupExe, true);
if (VelopackRuntimeInfo.IsWindows) {

View File

@@ -6,6 +6,7 @@ using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NuGet.Versioning;
using Velopack.Sources;
namespace Velopack.Packaging
{
@@ -15,7 +16,7 @@ namespace Velopack.Packaging
private readonly ILogger _logger;
private Dictionary<string, List<ReleaseEntry>> _releases;
public const string DEFAULT_CHANNEL = "default";
private const string BLANK_CHANNEL = "default";
public ReleaseEntryHelper(string outputDir, ILogger logger)
{
@@ -24,7 +25,7 @@ namespace Velopack.Packaging
_releases = new Dictionary<string, List<ReleaseEntry>>(StringComparer.OrdinalIgnoreCase);
foreach (var releaseFile in Directory.EnumerateFiles(outputDir, "RELEASES*")) {
var fn = Path.GetFileName(releaseFile);
var channel = fn.StartsWith("RELEASES-", StringComparison.OrdinalIgnoreCase) ? fn.Substring(9) : DEFAULT_CHANNEL;
var channel = fn.StartsWith("RELEASES-", StringComparison.OrdinalIgnoreCase) ? fn.Substring(9) : BLANK_CHANNEL;
var releases = ReleaseEntry.ParseReleaseFile(File.ReadAllText(releaseFile)).ToList();
_logger.Info($"Loaded {releases.Count} entries from: {releaseFile}");
// this allows us to collapse RELEASES files with the same channel but different case on file systems
@@ -37,14 +38,12 @@ namespace Velopack.Packaging
}
}
public void ValidateEntriesForPackaging(SemanticVersion version, string channel)
public void ValidateChannelForPackaging(SemanticVersion version, string channel, RID rid)
{
if (!_releases.ContainsKey(channel) || !_releases[channel].Any())
return;
RID rid = null;
foreach (var release in _releases[channel]) {
if (rid == null) rid = release.Rid;
if (release.Rid != rid) {
throw new ArgumentException("All releases in a channel must have the same RID. Please correct RELEASES file or change channel name: " + GetReleasePath(channel));
}
@@ -60,7 +59,7 @@ namespace Velopack.Packaging
if (releases == null || !releases.Any()) return null;
var entry = releases
.Where(x => x.IsDelta == false)
.Where(x => x.Version < version)
.Where(x => VersionComparer.Version.Compare(x.Version, version) < 0)
.OrderByDescending(x => x.Version)
.FirstOrDefault();
if (entry == null) return null;
@@ -68,6 +67,13 @@ namespace Velopack.Packaging
return new ReleasePackageBuilder(_logger, file, true);
}
public ReleaseEntry GetLatestFullRelease(string channel)
{
var releases = _releases.ContainsKey(channel) ? _releases[channel] : null;
if (releases == null || !releases.Any()) return null;
return releases.Where(z => !z.IsDelta).MaxBy(z => z.Version).First();
}
public void AddRemoteReleaseEntries(IEnumerable<ReleaseEntry> entries, string channel)
{
if (!_releases.ContainsKey(channel))
@@ -110,49 +116,105 @@ namespace Velopack.Packaging
}
}
private string GetReleasePath(string channel)
public static string GetPkgSuffix(RuntimeOs os, string channel)
{
return Path.Combine(_outputDir, channel == DEFAULT_CHANNEL ? "RELEASES" : $"RELEASES-{channel.ToLower()}");
if (channel == null) return "";
if (channel == BLANK_CHANNEL) return "";
if (channel == "osx" && os == RuntimeOs.OSX) return "";
return "-" + channel.ToLower();
}
public IEnumerable<FileInfo> GetUploadAssets()
public static string GetDefaultChannel(RuntimeOs os)
{
if (os == RuntimeOs.Windows) return BLANK_CHANNEL;
if (os == RuntimeOs.OSX) return "osx";
throw new NotSupportedException("Unsupported OS: " + os);
}
public string GetReleasePath(string channel)
{
return Path.Combine(_outputDir, SourceBase.GetReleasesFileNameImpl(channel));
}
public enum AssetsMode
{
AllPackages,
OnlyLatest,
}
public class AssetUploadInfo
{
public List<FileInfo> Files { get; } = new List<FileInfo>();
public List<ReleaseEntry> Releases { get; } = new List<ReleaseEntry>();
public string ReleasesFileName { get; set; }
}
public AssetUploadInfo GetUploadAssets(string channel, AssetsMode mode)
{
var ret = new AssetUploadInfo();
var os = VelopackRuntimeInfo.SystemOs;
channel ??= GetDefaultChannel(os);
var suffix = GetPkgSuffix(os, channel);
if (!_releases.ContainsKey(channel))
throw new ArgumentException("No releases found for channel: " + channel);
ret.ReleasesFileName = SourceBase.GetReleasesFileNameImpl(channel);
var relPath = GetReleasePath(channel);
if (!File.Exists(relPath))
throw new FileNotFoundException("Could not find RELEASES file for channel: " + channel, relPath);
ReleaseEntry latest = GetLatestFullRelease(channel);
if (latest == null) {
throw new ArgumentException("No full releases found for channel: " + channel);
} else {
_logger.Info("Latest local release: " + latest.OriginalFilename);
}
foreach (var rel in Directory.EnumerateFiles(_outputDir, "*.nupkg")) {
if (_releases.Any(kvp => kvp.Value.Any(x => Path.GetFileName(rel).Equals(x.OriginalFilename, StringComparison.OrdinalIgnoreCase)))) {
yield return new FileInfo(rel);
} else {
_logger.Warn($"Asset '{rel}' is not in any RELEASES file, it will be ignored.");
var entry = _releases[channel].FirstOrDefault(x => Path.GetFileName(rel).Equals(x.OriginalFilename, StringComparison.OrdinalIgnoreCase));
if (entry != null) {
if (mode != AssetsMode.OnlyLatest || latest.Version == entry.Version) {
_logger.Info($"Discovered asset: {rel}");
ret.Files.Add(new FileInfo(rel));
ret.Releases.Add(entry);
}
}
}
foreach (var rel in Directory.EnumerateFiles(_outputDir, "*-Portable.zip")) {
yield return new FileInfo(rel);
foreach (var rel in Directory.EnumerateFiles(_outputDir, $"*{suffix}-Portable.zip")) {
_logger.Info($"Discovered asset: {rel}");
ret.Files.Add(new FileInfo(rel));
}
foreach (var rel in Directory.EnumerateFiles(_outputDir, "*-Setup.exe")) {
yield return new FileInfo(rel);
foreach (var rel in Directory.EnumerateFiles(_outputDir, $"*{suffix}-Setup.exe")) {
_logger.Info($"Discovered asset: {rel}");
ret.Files.Add(new FileInfo(rel));
}
foreach (var rel in Directory.EnumerateFiles(_outputDir, "*-Setup.pkg")) {
yield return new FileInfo(rel);
foreach (var rel in Directory.EnumerateFiles(_outputDir, $"*{suffix}-Setup.pkg")) {
_logger.Info($"Discovered asset: {rel}");
ret.Files.Add(new FileInfo(rel));
}
foreach (var rel in Directory.EnumerateFiles(_outputDir, "RELEASES*")) {
yield return new FileInfo(rel);
}
return ret;
}
public string GetSuggestedPortablePath(string id, RID rid)
public string GetSuggestedPortablePath(string id, string channel, RID rid)
{
return Path.Combine(_outputDir, $"{id}-[{rid.ToDisplay(RidDisplayType.NoVersion)}]-Portable.zip");
var suffix = GetPkgSuffix(rid.BaseRID, channel);
return Path.Combine(_outputDir, $"{id}-[{rid.ToDisplay(RidDisplayType.NoVersion)}]{suffix}-Portable.zip");
}
public string GetSuggestedSetupPath(string id, RID rid)
public string GetSuggestedSetupPath(string id, string channel, RID rid)
{
var suffix = GetPkgSuffix(rid.BaseRID, channel);
if (rid.BaseRID == RuntimeOs.Windows)
return Path.Combine(_outputDir, $"{id}-[{rid.ToDisplay(RidDisplayType.NoVersion)}]-Setup.exe");
return Path.Combine(_outputDir, $"{id}-[{rid.ToDisplay(RidDisplayType.NoVersion)}]{suffix}-Setup.exe");
else if (rid.BaseRID == RuntimeOs.OSX)
return Path.Combine(_outputDir, $"{id}-[{rid.ToDisplay(RidDisplayType.NoVersion)}]-Setup.pkg");
return Path.Combine(_outputDir, $"{id}-[{rid.ToDisplay(RidDisplayType.NoVersion)}]{suffix}-Setup.pkg");
else
throw new NotSupportedException("RID not supported: " + rid);
}

View File

@@ -26,8 +26,6 @@ public class OsxPackCommand : OsxBundleCommand
public string NotaryProfile { get; private set; }
public string Channel { get; private set; }
public OsxPackCommand()
: base("pack", "Converts application files into a release and installer.")
{
@@ -40,12 +38,6 @@ public class OsxPackCommand : OsxBundleCommand
.SetDefault(DeltaMode.BestSpeed)
.SetDescription("Set the delta generation mode.");
AddOption<string>((v) => Channel = v, "-c", "--channel")
.SetDescription("Release channel to use when creating the package.")
.SetDefault("osx")
.RequiresValidNuGetId()
.SetArgumentHelpName("NAME");
AddOption<bool>((v) => NoPackage = v, "--noPkg")
.SetDescription("Skip generating a .pkg installer.");

View File

@@ -15,8 +15,6 @@ public class S3BaseCommand : OutputCommand
public string Bucket { get; private set; }
public string PathPrefix { get; private set; }
protected S3BaseCommand(string name, string description)
: base(name, description)
{
@@ -50,10 +48,6 @@ public class S3BaseCommand : OutputCommand
.SetDescription("Name of the S3 bucket.")
.SetArgumentHelpName("NAME")
.SetRequired();
AddOption<string>((v) => PathPrefix = v, "--pathPrefix")
.SetDescription("A sub-folder used for files in the bucket, for creating release channels (eg. 'stable' or 'dev').")
.SetArgumentHelpName("PREFIX");
}
private static void MustBeValidAwsRegion(OptionResult result)

View File

@@ -5,18 +5,12 @@ public class S3UploadCommand : S3BaseCommand
{
public bool Overwrite { get; private set; }
public int KeepMaxReleases { get; private set; }
public S3UploadCommand()
: base("s3", "Upload releases to a S3 bucket.")
{
AddOption<bool>((v) => Overwrite = v, "--overwrite")
.SetDescription("Replace remote files if local files have changed.");
AddOption<int>((v) => KeepMaxReleases = v, "--keepMaxReleases")
.SetDescription("Apply a retention policy which keeps only the specified number of old versions in remote source.")
.SetArgumentHelpName("NUMBER");
ReleaseDirectoryOption.SetRequired();
ReleaseDirectoryOption.MustNotBeEmpty();
}

View File

@@ -16,8 +16,6 @@ public class WindowsReleasifyCommand : WindowsSigningCommand
public string EntryExecutableName { get; private set; }
public string Channel { get; private set; }
public WindowsReleasifyCommand()
: this("releasify", "Take an existing nuget package and convert it into a release.")
{
@@ -60,11 +58,5 @@ public class WindowsReleasifyCommand : WindowsSigningCommand
.SetDescription("The file name of the main/entry executable.")
.SetArgumentHelpName("NAME")
.RequiresExtension(".exe");
AddOption<string>((v) => Channel = v, "-c", "--channel")
.SetDescription("Release channel to use when creating the package.")
.SetDefault(ReleaseEntryHelper.DEFAULT_CHANNEL)
.RequiresValidNuGetId()
.SetArgumentHelpName("NAME");
}
}

View File

@@ -3,15 +3,20 @@ using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Velopack.Packaging;
namespace Velopack.Vpk.Commands
{
public class OutputCommand : BaseCommand
public abstract class OutputCommand : BaseCommand
{
public string ReleaseDirectory { get; private set; }
public string Channel { get; private set; }
protected CliOption<DirectoryInfo> ReleaseDirectoryOption { get; private set; }
protected CliOption<string> ChannelOption { get; private set; }
protected OutputCommand(string name, string description)
: base(name, description)
{
@@ -19,6 +24,12 @@ namespace Velopack.Vpk.Commands
.SetDescription("Output directory for created packages.")
.SetArgumentHelpName("DIR")
.SetDefault(new DirectoryInfo(".\\Releases"));
ChannelOption = AddOption<string>((v) => Channel = v, "-c", "--channel")
.SetDescription("The channel to use for this release.")
.RequiresValidNuGetId()
.SetArgumentHelpName("NAME")
.SetDefault(ReleaseEntryHelper.GetDefaultChannel(VelopackRuntimeInfo.SystemOs));
}
public DirectoryInfo GetReleaseDirectory()

View File

@@ -6,24 +6,19 @@ using System.Threading.Tasks;
namespace Velopack.Vpk.Commands
{
public class PlatformCommand : OutputCommand
public abstract class PlatformCommand : OutputCommand
{
public string TargetRuntime { get; set; }
//public FileSystemInfo SolutionDir { get; set; }
protected CliOption<string> TargetRuntimeOption { get; private set; }
protected PlatformCommand(string name, string description) : base(name, description)
{
TargetRuntime = VelopackRuntimeInfo.SystemOs.GetOsShortName();
AddOption<string>((v) => TargetRuntime = v, "-r", "--runtime")
TargetRuntimeOption = AddOption<string>((v) => TargetRuntime = v, "-r", "--runtime")
.SetDescription("The target runtime to build packages for.")
.SetArgumentHelpName("RID")
.SetDefault(VelopackRuntimeInfo.SystemOs.GetOsShortName())
.MustBeSupportedRid();
//AddOption<FileSystemInfo>((v) => SolutionDir = v, "--sln")
// .SetDescription("Explicit path to project solution (.sln)")
// .AcceptExistingOnly();
}
public RID GetRid() => RID.Parse(TargetRuntime ?? VelopackRuntimeInfo.SystemOs.GetOsShortName());

View File

@@ -122,8 +122,9 @@ public class EmbeddedRunner : ICommandRunner
ReleaseDir = command.GetReleaseDirectory(),
RepoUrl = command.RepoUrl,
Token = command.Token,
Channel = command.Channel,
};
return new GitHubRepository(_logger).DownloadRecentPackages(options);
return new GitHubRepository(_logger).DownloadLatestFullPackageAsync(options);
}
public virtual Task ExecuteGithubUpload(GitHubUploadCommand command)
@@ -134,8 +135,9 @@ public class EmbeddedRunner : ICommandRunner
Token = command.Token,
Publish = command.Publish,
ReleaseName = command.ReleaseName,
Channel = command.Channel,
};
return new GitHubRepository(_logger).UploadMissingPackages(options);
return new GitHubRepository(_logger).UploadMissingAssetsAsync(options);
}
public virtual Task ExecuteHttpDownload(HttpDownloadCommand command)
@@ -143,23 +145,24 @@ public class EmbeddedRunner : ICommandRunner
var options = new HttpDownloadOptions {
ReleaseDir = command.GetReleaseDirectory(),
Url = command.Url,
Channel = command.Channel,
};
return new SimpleWebRepository(_logger).DownloadRecentPackages(options);
return new HttpRepository(_logger).DownloadLatestFullPackageAsync(options);
}
public virtual Task ExecuteS3Download(S3DownloadCommand command)
{
var options = new S3Options {
var options = new S3DownloadOptions {
Bucket = command.Bucket,
Endpoint = command.Endpoint,
Session = command.Session,
KeyId = command.KeyId,
PathPrefix = command.PathPrefix,
Region = command.Region,
ReleaseDir = command.GetReleaseDirectory(),
Session = command.Session,
Secret = command.Secret,
Channel = command.Channel,
};
return new S3Repository(_logger).DownloadRecentPackages(options);
return new S3Repository(_logger).DownloadLatestFullPackageAsync(options);
}
public virtual Task ExecuteS3Upload(S3UploadCommand command)
@@ -168,14 +171,14 @@ public class EmbeddedRunner : ICommandRunner
Bucket = command.Bucket,
Endpoint = command.Endpoint,
KeyId = command.KeyId,
PathPrefix = command.PathPrefix,
Session = command.Session,
Region = command.Region,
ReleaseDir = command.GetReleaseDirectory(),
Secret = command.Secret,
KeepMaxReleases = command.KeepMaxReleases,
Overwrite = command.Overwrite,
Channel = command.Channel,
};
return new S3Repository(_logger).UploadMissingPackages(options);
return new S3Repository(_logger).UploadMissingAssetsAsync(options);
}
public virtual Task ExecuteDeltaGen(DeltaGenCommand command)

View File

@@ -23,7 +23,6 @@ namespace Velopack
AppendArgumentsTo(sb, args);
debug = sb.ToString();
}
#else
public static void AppendArgumentListSafe(this ProcessStartInfo psi, IEnumerable<string> args, out string debug)
{
@@ -59,57 +58,68 @@ namespace Velopack
return p;
}
public static Task<string> Output(this ProcessStartInfo psi, int timeoutMs)
public static string Output(this ProcessStartInfo psi, int timeoutMs)
{
TaskCompletionSource<string> tcs = new TaskCompletionSource<string>();
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
psi.UseShellExecute = false;
bool killed = false;
var p = Process.Start(psi);
if (!p.WaitForExit(timeoutMs)) {
p.Kill();
throw new TimeoutException("Process did not exit within alloted time.");
}
var sb = new StringBuilder();
var p = new Process();
p.StartInfo = psi;
p.EnableRaisingEvents = true;
return p.StandardOutput.ReadToEnd().Trim();
p.Exited += (o, e) => {
if (killed) return;
if (p.ExitCode != 0) {
tcs.SetException(new Exception($"Process exited with code {p.ExitCode}."));
} else {
tcs.SetResult(sb.ToString());
}
};
//TaskCompletionSource<string> tcs = new TaskCompletionSource<string>();
p.ErrorDataReceived += (o, e) => {
if (killed) return;
if (e.Data != null) {
sb.AppendLine(e.Data);
}
};
//psi.RedirectStandardOutput = true;
//psi.RedirectStandardError = true;
//psi.UseShellExecute = false;
p.OutputDataReceived += (o, e) => {
if (killed) return;
if (e.Data != null) {
sb.AppendLine(e.Data);
}
};
//bool killed = false;
p.Start();
p.BeginErrorReadLine();
p.BeginOutputReadLine();
//var sb = new StringBuilder();
//var p = new Process();
//p.StartInfo = psi;
//p.EnableRaisingEvents = true;
Task.Delay(timeoutMs).ContinueWith(t => {
killed = true;
if (!tcs.Task.IsCompleted) {
tcs.SetException(new TimeoutException($"Process timed out after {timeoutMs}ms."));
p.Kill();
}
});
//p.Exited += (o, e) => {
// if (killed) return;
// if (p.ExitCode != 0) {
// tcs.SetException(new Exception($"Process exited with code {p.ExitCode}."));
// } else {
// tcs.SetResult(sb.ToString());
// }
//};
return tcs.Task;
//p.ErrorDataReceived += (o, e) => {
// if (killed) return;
// if (e.Data != null) {
// sb.AppendLine(e.Data);
// }
//};
//p.OutputDataReceived += (o, e) => {
// if (killed) return;
// if (e.Data != null) {
// sb.AppendLine(e.Data);
// }
//};
//p.Start();
//p.BeginErrorReadLine();
//p.BeginOutputReadLine();
//Task.Delay(timeoutMs).ContinueWith(t => {
// killed = true;
// if (!tcs.Task.IsCompleted) {
// tcs.SetException(new TimeoutException($"Process timed out after {timeoutMs}ms."));
// p.Kill();
// }
//});
//return tcs.Task;
}

View File

@@ -61,7 +61,7 @@ namespace Velopack.NuGet
}
}
public static void SetMetadata(string nuspecPath, string mainExe, IEnumerable<string> runtimes, RID rid)
public static void SetMetadata(string nuspecPath, string mainExe, IEnumerable<string> runtimes, RID rid, string version)
{
Dictionary<string, string> toSet = new();
@@ -78,9 +78,12 @@ namespace Velopack.NuGet
toSet.Add("machineArchitecture", rid.Architecture.ToString());
}
if (!String.IsNullOrEmpty(mainExe))
if (!String.IsNullOrWhiteSpace(mainExe))
toSet.Add("mainExe", mainExe);
if (!String.IsNullOrWhiteSpace(version))
toSet.Add("version", version);
if (!toSet.Any())
return;

View File

@@ -137,6 +137,9 @@ namespace Velopack.Sources
AccessToken = accessToken;
Prerelease = prerelease;
Downloader = downloader ?? Utility.CreateDefaultDownloader();
if (String.IsNullOrWhiteSpace(AccessToken))
logger?.Warn("No GitHub access token provided. Unauthenticated requests will be limited to 60 per hour.");
}
/// <inheritdoc />

View File

@@ -26,11 +26,15 @@ namespace Velopack.Sources
/// <summary> Get the RELEASES file name for the specified Channel </summary>
protected virtual string GetReleasesFileName()
{
if (String.IsNullOrWhiteSpace(Channel)) {
return GetReleasesFileNameImpl(Channel);
}
internal static string GetReleasesFileNameImpl(string channel)
{
if (String.IsNullOrWhiteSpace(channel) || channel == "default") {
return VelopackRuntimeInfo.IsOSX ? "RELEASES-osx" : "RELEASES";
}
return $"RELEASES-{Channel.ToLower()}";
return $"RELEASES-{channel.ToLower()}";
}
/// <inheritdoc/>

View File

@@ -73,16 +73,16 @@ public abstract class S3CommandTests<T> : BaseCommandTests<T>
Assert.StartsWith("Cannot use '--region' and '--endpoint' options together", parseResult.Errors[0].Message);
}
[Fact]
public void PathPrefix_WithPath_ParsesValue()
{
S3BaseCommand command = new T();
//[Fact]
//public void PathPrefix_WithPath_ParsesValue()
//{
// S3BaseCommand command = new T();
string cli = GetRequiredDefaultOptions() + $"--pathPrefix \"sub-folder\"";
ParseResult parseResult = command.ParseAndApply(cli);
// string cli = GetRequiredDefaultOptions() + $"--pathPrefix \"sub-folder\"";
// ParseResult parseResult = command.ParseAndApply(cli);
Assert.Equal("sub-folder", command.PathPrefix);
}
// Assert.Equal("sub-folder", command.PathPrefix);
//}
protected override string GetRequiredDefaultOptions()
{
@@ -108,14 +108,14 @@ public class S3UploadCommandTests : S3CommandTests<S3UploadCommand>
Assert.True(command.Overwrite);
}
[Fact]
public void KeepMaxReleases_WithNumber_ParsesValue()
{
var command = new S3UploadCommand();
//[Fact]
//public void KeepMaxReleases_WithNumber_ParsesValue()
//{
// var command = new S3UploadCommand();
string cli = GetRequiredDefaultOptions() + "--keepMaxReleases 42";
ParseResult parseResult = command.ParseAndApply(cli);
// string cli = GetRequiredDefaultOptions() + "--keepMaxReleases 42";
// ParseResult parseResult = command.ParseAndApply(cli);
Assert.Equal(42, command.KeepMaxReleases);
}
// Assert.Equal(42, command.KeepMaxReleases);
//}
}

View File

@@ -58,18 +58,19 @@ public class WindowsPackTests
PackTitle = "Test Squirrel App",
PackDirectory = tmpOutput,
IncludePdb = true,
Channel = "asd123"
};
var runner = new WindowsPackCommandRunner(logger);
runner.Pack(options);
var nupkgPath = Path.Combine(tmpReleaseDir, $"{id}-{version}-win-x64-full.nupkg");
var nupkgPath = Path.Combine(tmpReleaseDir, $"{id}-{version}-asd123-win-x64-full.nupkg");
Assert.True(File.Exists(nupkgPath));
var setupPath = Path.Combine(tmpReleaseDir, $"{id}-Setup-[win-x64].exe");
var setupPath = Path.Combine(tmpReleaseDir, $"{id}-[win-x64]-asd123-Setup.exe");
Assert.True(File.Exists(setupPath));
var releasesPath = Path.Combine(tmpReleaseDir, $"RELEASES");
var releasesPath = Path.Combine(tmpReleaseDir, $"RELEASES-asd123");
Assert.True(File.Exists(releasesPath));
EasyZip.ExtractZipToDirectory(logger, nupkgPath, unzipDir);
@@ -80,7 +81,7 @@ public class WindowsPackTests
var xml = XDocument.Load(nuspecPath);
Assert.Equal(id, xml.Root.ElementsNoNamespace("metadata").Single().ElementsNoNamespace("id").Single().Value);
Assert.Equal(version, xml.Root.ElementsNoNamespace("metadata").Single().ElementsNoNamespace("version").Single().Value);
Assert.Equal(version + "-asd123", xml.Root.ElementsNoNamespace("metadata").Single().ElementsNoNamespace("version").Single().Value);
Assert.Equal(exe, xml.Root.ElementsNoNamespace("metadata").Single().ElementsNoNamespace("mainExe").Single().Value);
Assert.Equal("Test Squirrel App", xml.Root.ElementsNoNamespace("metadata").Single().ElementsNoNamespace("title").Single().Value);
Assert.Equal("author", xml.Root.ElementsNoNamespace("metadata").Single().ElementsNoNamespace("authors").Single().Value);
@@ -95,7 +96,7 @@ public class WindowsPackTests
}
[SkippableFact]
public void PackBuildMultipleChannels()
public void PackBuildMultipleChannelsSameRid()
{
Skip.IfNot(VelopackRuntimeInfo.IsWindows);
@@ -122,42 +123,76 @@ public class WindowsPackTests
PackAuthors = "author",
PackTitle = "Test Squirrel App",
PackDirectory = tmpOutput,
Channel = "hello",
IncludePdb = true,
};
var runner = new WindowsPackCommandRunner(logger);
runner.Pack(options);
options.TargetRuntime = RID.Parse("win10.0.19043-x86");
options.Channel = "hello2";
runner.Pack(options);
var nupkgPath1 = Path.Combine(tmpReleaseDir, $"{id}-{version}-win-x64-full.nupkg");
Assert.True(File.Exists(nupkgPath1));
var setupPath1 = Path.Combine(tmpReleaseDir, $"{id}-Setup-[win-x64].exe");
var setupPath1 = Path.Combine(tmpReleaseDir, $"{id}-[win-x64]-Setup.exe");
Assert.True(File.Exists(setupPath1));
var releasesPath1 = Path.Combine(tmpReleaseDir, $"RELEASES-hello");
var releasesPath1 = Path.Combine(tmpReleaseDir, $"RELEASES");
Assert.True(File.Exists(releasesPath1));
var nupkgPath2 = Path.Combine(tmpReleaseDir, $"{id}-{version}-win-x86-full.nupkg");
Assert.True(File.Exists(nupkgPath2));
var setupPath2 = Path.Combine(tmpReleaseDir, $"{id}-Setup-[win-x86].exe");
Assert.True(File.Exists(setupPath2));
var releasesPath2 = Path.Combine(tmpReleaseDir, $"RELEASES-hello2");
Assert.True(File.Exists(releasesPath2));
var rel1 = ReleaseEntry.ParseReleaseFile(File.ReadAllText(releasesPath1, Encoding.UTF8));
Assert.Equal(1, rel1.Count());
Assert.True(rel1.Single().Rid == RID.Parse("win-x64"));
options.Channel = "hello";
runner.Pack(options);
var nupkgPath2 = Path.Combine(tmpReleaseDir, $"{id}-{version}-hello-win-x64-full.nupkg");
Assert.True(File.Exists(nupkgPath2));
var setupPath2 = Path.Combine(tmpReleaseDir, $"{id}-[win-x64]-hello-Setup.exe");
Assert.True(File.Exists(setupPath2));
var releasesPath2 = Path.Combine(tmpReleaseDir, $"RELEASES-hello");
Assert.True(File.Exists(releasesPath2));
rel1 = ReleaseEntry.ParseReleaseFile(File.ReadAllText(releasesPath1, Encoding.UTF8));
Assert.Equal(1, rel1.Count());
var rel2 = ReleaseEntry.ParseReleaseFile(File.ReadAllText(releasesPath2, Encoding.UTF8));
Assert.Equal(1, rel2.Count());
Assert.True(rel2.Single().Rid == RID.Parse("win-x86"));
}
[SkippableFact]
public void PackBuildRefuseSameVersion()
{
Skip.IfNot(VelopackRuntimeInfo.IsWindows);
using var logger = _output.BuildLoggerFor<WindowsPackTests>();
using var _1 = Utility.GetTempDirectory(out var tmpOutput);
using var _2 = Utility.GetTempDirectory(out var tmpReleaseDir);
var exe = "testawareapp.exe";
var pdb = Path.ChangeExtension(exe, ".pdb");
var id = "Test.Squirrel-App";
var version = "1.0.0";
File.Copy(HelperFile.FindTestFile(exe), Path.Combine(tmpOutput, exe));
File.Copy(HelperFile.FindTestFile(pdb), Path.Combine(tmpOutput, pdb));
var options = new WindowsPackOptions {
EntryExecutableName = exe,
ReleaseDir = new DirectoryInfo(tmpReleaseDir),
PackId = id,
PackVersion = version,
PackDirectory = tmpOutput,
TargetRuntime = RID.Parse("win"),
};
var runner = new WindowsPackCommandRunner(logger);
runner.Pack(options);
Assert.Throws<ArgumentException>(() => runner.Pack(options));
}
[SkippableFact]
@@ -261,7 +296,7 @@ public class WindowsPackTests
var runner = new WindowsPackCommandRunner(logger);
runner.Pack(options);
var setupPath1 = Path.Combine(tmpReleaseDir, $"{id}-Setup-[win-x64].exe");
var setupPath1 = Path.Combine(tmpReleaseDir, $"{id}-[win-x64]-Setup.exe");
Assert.True(File.Exists(setupPath1));
RunNoCoverage(setupPath1, new[] { "--nocolor", "--silent", "--installto", tmpInstallDir }, Environment.CurrentDirectory, logger);
@@ -322,7 +357,7 @@ public class WindowsPackTests
PackTestApp(id, "1.0.0", "version 1 test", releaseDir, logger);
// install app
var setupPath1 = Path.Combine(releaseDir, $"{id}-Setup-[win-x64].exe");
var setupPath1 = Path.Combine(releaseDir, $"{id}-[win-x64]-Setup.exe");
RunNoCoverage(setupPath1, new string[] { "--nocolor", "--silent", "--installto", installDir },
Environment.GetFolderPath(Environment.SpecialFolder.Desktop), logger);
@@ -396,7 +431,7 @@ public class WindowsPackTests
PackTestApp(id, "1.0.0", "version 1 test", releaseDir, logger);
// install app
var setupPath1 = Path.Combine(releaseDir, $"{id}-Setup-[win-x64].exe");
var setupPath1 = Path.Combine(releaseDir, $"{id}-[win-x64]-Setup.exe");
RunNoCoverage(setupPath1, new string[] { "--nocolor", "--installto", installDir },
Environment.GetFolderPath(Environment.SpecialFolder.Desktop), logger);
@@ -445,7 +480,7 @@ public class WindowsPackTests
PackTestApp(id, "1.0.0", "version 1 test", releaseDir, logger);
// install app
var setupPath1 = Path.Combine(releaseDir, $"{id}-Setup-[win-x64].exe");
var setupPath1 = Path.Combine(releaseDir, $"{id}-[win-x64]-Setup.exe");
RunNoCoverage(setupPath1, new string[] { "--nocolor", "--silent", "--installto", installDir },
Environment.GetFolderPath(Environment.SpecialFolder.Desktop), logger);
@@ -610,6 +645,9 @@ public class WindowsPackTests
{
var outputfile = GetPath($"coverage.rundotnet.{RandomString(8)}.xml");
if (!File.Exists(exe))
throw new Exception($"File {exe} does not exist.");
var psi = new ProcessStartInfo("dotnet-coverage");
psi.WorkingDirectory = workingDir;
psi.CreateNoWindow = true;
@@ -637,6 +675,9 @@ public class WindowsPackTests
private string RunNoCoverage(string exe, string[] args, string workingDir, ILogger logger, int? exitCode = 0)
{
if (!File.Exists(exe))
throw new Exception($"File {exe} does not exist.");
var psi = new ProcessStartInfo(exe);
psi.WorkingDirectory = workingDir;
psi.CreateNoWindow = true;