Files
duplicati/ReleaseBuilder/CliCommand/Build.cs
T

441 lines
19 KiB
C#

using System.CommandLine;
namespace ReleaseBuilder.CliCommand;
/// <summary>
/// The build command implementation
/// </summary>
public static partial class Build
{
/// <summary>
/// The primary project to build for GUI builds
/// </summary>
private const string PrimaryGUIProject = "Duplicati.GUI.TrayIcon.csproj";
/// <summary>
/// The secondary project to build for CLI builds
/// </summary>
private const string PrimaryCLIProject = "Duplicati.CommandLine.csproj";
/// <summary>
/// Projects that only makes sense for Windows
/// </summary>
private static readonly IReadOnlySet<string> WindowsOnlyProjects = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { "Duplicati.WindowsService.csproj" };
/// <summary>
/// Projects the pull in GUI dependencies
/// </summary>
private static readonly IReadOnlySet<string> GUIProjects = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { "Duplicati.GUI.TrayIcon.csproj" };
/// <summary>
/// Some executables have shorter names that follow the Linux convention of all-lowercase
/// </summary>
private static readonly IDictionary<string, string> ExecutableRenames = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase)
{
{ "Duplicati.CommandLine", "duplicati-cli" },
{ "Duplicati.Server", "duplicati-server"},
{ "Duplicati.CommandLine.BackendTester", "duplicati-backend-tester"},
{ "Duplicati.CommandLine.BackendTool", "duplicati-backend-tool" },
{ "Duplicati.CommandLine.RecoveryTool", "duplicati-recovery-tool" },
{ "Duplicati.GUI.TrayIcon", "duplicati" }
};
/// <summary>
/// Name of the app bundle for MacOS
/// </summary>
private const string MacOSAppName = "Duplicati.app";
/// <summary>
/// Setup of the current runtime information
/// </summary>
/// <param name="ReleaseInfo">The release info for the current build</param>
/// <param name="KeyfilePassword">The keyfile password</param>
private class RuntimeConfig
{
/// <summary>
/// Constructs a new <see cref="RuntimeConfig"/>
/// </summary>
/// <param name="releaseInfo">The release info to use</param>
/// <param name="keyfilePassword">The keyfile password to use</param>
/// <param name="executables">The executables</param>
public RuntimeConfig(ReleaseInfo releaseInfo, string keyfilePassword, IEnumerable<string> executables)
{
ReleaseInfo = releaseInfo;
KeyfilePassword = keyfilePassword;
ExecutableBinaries = executables;
}
/// <summary>
/// The cached password for the pfx file
/// </summary>
private string? _pfxPassword = null;
/// <summary>
/// The release info for this run
/// </summary>
public ReleaseInfo ReleaseInfo { get; }
/// <summary>
/// The keyfile password for this run
/// </summary>
public string KeyfilePassword { get; }
/// <summary>
/// The executables that should exist in the build folder
/// </summary>
public IEnumerable<string> ExecutableBinaries { get; }
/// <summary>
/// Gets the PFX password and throws if not possible
/// </summary>
public string PfxPassword
=> string.IsNullOrWhiteSpace(_pfxPassword)
? _pfxPassword = GetAuthenticodePassword(KeyfilePassword)
: _pfxPassword;
/// <summary>
/// Cache value for checking if authenticode signing is enabled
/// </summary>
private bool? _useAuthenticodeSigning;
/// <summary>
/// Checks if Authenticode signing should be enabled
/// </summary>
public void ToggleAuthenticodeSigning()
{
if (!_useAuthenticodeSigning.HasValue)
{
if (Program.Configuration.IsAuthenticodePossible())
_useAuthenticodeSigning = true;
else
{
if (ConsoleHelper.ReadInput("Configuration missing for osslsigncode, continue without signing executables?", "Y", "n") == "Y")
{
_useAuthenticodeSigning = false;
return;
}
throw new Exception("Configuration is not set up for osslsigncode");
}
}
}
/// <summary>
/// Cache value for checking if codesign is possible
/// </summary>
private bool? _useCodeSignSigning;
/// <summary>
/// Checks if codesign is enabled
/// </summary>
public void ToggleSignCodeSigning()
{
if (!_useCodeSignSigning.HasValue)
{
if (!OperatingSystem.IsMacOS())
_useCodeSignSigning = false;
else if (Program.Configuration.IsCodeSignPossible())
_useCodeSignSigning = true;
else
{
if (ConsoleHelper.ReadInput("Configuration missing for signcode, continue without signing executables?", "Y", "n") == "Y")
{
_useCodeSignSigning = false;
return;
}
throw new Exception("Configuration is not set up for signcode");
}
}
}
/// <summary>
/// Returns a value indicating if signcode is enabled
/// </summary>
public bool UseCodeSignSigning => _useCodeSignSigning!.Value;
/// <summary>
/// Returns a value indicating if authenticode signing is enabled
/// </summary>
public bool UseAuthenticodeSigning => _useAuthenticodeSigning!.Value;
/// <summary>
/// Decrypts the password file and returns the PFX password
/// </summary>
/// <param name="keyfilepassword">Password for the password file</param>
/// <returns>The Authenticode password</returns>
private string GetAuthenticodePassword(string keyfilepassword)
=> EncryptionHelper.DecryptPasswordFile(Program.Configuration.ConfigFiles.AuthenticodePasswordFile, keyfilepassword);
/// <summary>
/// Performs authenticode signing if enabled
/// </summary>
/// <param name="file">The file to sign</param>
/// <returns>An awaitable task</returns>
public Task AuthenticodeSign(string file)
=> UseAuthenticodeSigning
? ProcessRunner.OsslCodeSign(
Program.Configuration.Commands.OsslSignCode!,
Program.Configuration.ConfigFiles.AuthenticodePfxFile,
PfxPassword,
file)
: Task.CompletedTask;
/// <summary>
/// Performs codesign on the given file
/// </summary>
/// <param name="file">The file to sign</param>
/// <param name="entitlements">The entitlements to apply</param>
/// <returns>An awaitable task</returns>
public Task Codesign(string file, string entitlements)
=> UseCodeSignSigning
? ProcessRunner.MacOSCodeSign(
Program.Configuration.Commands.Codesign!,
Program.Configuration.ConfigFiles.CodesignIdentity,
entitlements,
file
)
: Task.CompletedTask;
/// <summary>
/// Performs productsign on the given file
/// </summary>
/// <param name="file">The file to sign</param>
/// <returns>An awaitable task</returns>
public Task Productsign(string file)
=> UseCodeSignSigning
? ProcessRunner.MacOSProductSign(
Program.Configuration.Commands.Productsign!,
Program.Configuration.ConfigFiles.CodesignIdentity,
file
)
: Task.CompletedTask;
}
/// <summary>
/// Structure for keeping all variables for a single release
/// </summary>
/// <param name="Version">The version to use</param>
/// <param name="Type">The release type</param>
/// <param name="Timestamp">The release timestamp</param>
private record ReleaseInfo(Version Version, ReleaseType Type, DateTime Timestamp)
{
/// <summary>
/// Gets the string name for the release
/// </summary>
public string ReleaseName => $"{Version}_{Type.ToString().ToLowerInvariant()}_{Timestamp:yyy-MM-dd}";
/// <summary>
/// Create a new release info
/// </summary>
/// <param name="type">The release type</param>
/// <param name="incVersion">The incremental version</param>
/// <returns>The release info</returns>
public static ReleaseInfo Create(ReleaseType type, int incVersion)
=> new ReleaseInfo(new Version(2, 0, 0, incVersion), type, DateTime.Today);
}
/// <summary>
/// Creates the build command
/// </summary>
/// <returns>The command</returns>
public static Command Create()
{
var buildTargetOption = new Option<PackageTarget[]>(
name: "--targets",
description: "The possible build targets, multiple arguments supported. Use the format os-arch.package, example: x64-win.msi.",
parseArgument: arg =>
{
if (!arg.Tokens.Any())
return Program.SupportedPackageTargets.ToArray();
var requested = arg.Tokens.Select(x => PackageTarget.ParsePackageId(x.Value)).Distinct().ToArray();
var invalid = requested.Where(x => !Program.SupportedPackageTargets.Contains(x)).ToList();
if (invalid.Any())
throw new Exception($"Following targets are not supported: {string.Join(", ", invalid.Select(x => x.PackageTargetString))}");
return requested;
});
var releaseTypeOption = new Argument<ReleaseType>(
name: "type",
description: "The release type",
getDefaultValue: () => ReleaseType.Canary
);
var gitStashPushOption = new Option<bool>(
name: "--git-stash",
description: "Performs a git stash command before running the build, and a git commit after updating files",
getDefaultValue: () => true
);
var keepBuildsOption = new Option<bool>(
name: "--keep-build",
description: "Do not delete the build folders if they already exist (re-use build)",
getDefaultValue: () => false
);
var buildTempOption = new Option<DirectoryInfo>(
name: "--build-path",
description: "The path to the temporary folder used for builds; will be deleted on startup",
getDefaultValue: () => new DirectoryInfo(Path.GetFullPath("build-temp"))
);
var solutionFileOption = new Option<FileInfo>(
name: "--solution-path",
description: "Path to the Duplicati.sln file",
getDefaultValue: () => new FileInfo(Path.GetFullPath(Path.Combine("..", "Duplicati.sln")))
);
var updateUrlsOption = new Option<string>(
name: "--update-urls",
description: "The updater urls where the client will check for updates",
getDefaultValue: () => "https://updates.duplicati.com/${RELEASE_TYPE}/latest-v2.manifest;https://alt.updates.duplicati.com/${RELEASE_TYPE}/latest-v2.manifest"
);
var command = new Command("build", "Builds the packages for a release") {
gitStashPushOption,
releaseTypeOption,
buildTempOption,
buildTargetOption,
solutionFileOption,
updateUrlsOption,
keepBuildsOption
};
command.SetHandler(async (buildTargets, buildTemp, solutionFile, gitStashPush, releaseType, updateUrls, keepBuilds) =>
{
Console.WriteLine($"Building {releaseType} release ...");
if (!buildTargets.Any())
buildTargets = Program.SupportedPackageTargets.ToArray();
if (!solutionFile.Exists)
throw new FileNotFoundException($"Solution file not found: {solutionFile.FullName}");
// This could be fixed, so we will throw an exception if the build is not possible
if (buildTargets.Any(x => x.Package == PackageType.MSI) && !Program.Configuration.IsMSIBuildPossible())
throw new Exception("WiX toolset not configured, cannot build MSI files");
// This will be fixed in the future, but requires a new http-interface for Synology DSM
if (buildTargets.Any(x => x.Package == PackageType.SynologySpk) && !Program.Configuration.IsSynologyPkgPossible())
throw new Exception("Synology SPK files are currently not supported");
// This will not work, so to make it easier for non-MacOS developers, we will remove the MacOS packages
if (buildTargets.Any(x => x.Package == PackageType.MacPkg || x.Package == PackageType.DMG) && !Program.Configuration.IsMacPkgBuildPossible())
{
Console.WriteLine("MacOS packages requested but not running on MacOS, removing from build targets");
buildTargets = buildTargets.Where(x => x.Package != PackageType.MacPkg && x.Package != PackageType.DMG).ToArray();
}
var baseDir = Path.GetDirectoryName(solutionFile.FullName) ?? throw new Exception("Path to solution file was invalid");
var versionFilePath = Path.Combine(baseDir, "Updates", "build_version.txt");
if (!File.Exists(versionFilePath))
throw new FileNotFoundException($"Version file not found: {versionFilePath}");
var sourceProjects = Directory.EnumerateDirectories(Path.Combine(baseDir, "Executables", "net8"), "*", SearchOption.TopDirectoryOnly)
.SelectMany(x => Directory.EnumerateFiles(x, "*.csproj", SearchOption.TopDirectoryOnly))
.ToList();
var primaryGUI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryGUIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find tray icon executable");
var primaryCLI = sourceProjects.FirstOrDefault(x => string.Equals(Path.GetFileName(x), PrimaryCLIProject, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception("Failed to find cli executable");
var windowsOnly = sourceProjects.Where(x => WindowsOnlyProjects.Contains(Path.GetFileName(x))).ToHashSet(StringComparer.OrdinalIgnoreCase);
// Put primary at the end
sourceProjects.Remove(primaryGUI);
sourceProjects.Remove(primaryCLI);
sourceProjects.Add(primaryCLI);
sourceProjects.Add(primaryGUI);
if (!File.Exists(primaryGUI))
throw new Exception($"Failed to locate project file: {primaryGUI}");
if (!File.Exists(primaryCLI))
throw new Exception($"Failed to locate project file: {primaryCLI}");
var releaseInfo = ReleaseInfo.Create(releaseType, int.Parse(File.ReadAllText(versionFilePath)) + 1);
Console.WriteLine($"Building {releaseInfo.ReleaseName} ...");
var keyfilePassword = ConsoleHelper.ReadPassword("Enter keyfile password");
// Configure runtime environment
var rtcfg = new RuntimeConfig(releaseInfo, keyfilePassword, sourceProjects.Select(x => Path.GetFileNameWithoutExtension(x)).ToList());
rtcfg.ToggleAuthenticodeSigning();
rtcfg.ToggleSignCodeSigning();
if (!keepBuilds && Directory.Exists(buildTemp.FullName))
{
Console.WriteLine($"Deleting build folder: {buildTemp.FullName}");
Directory.Delete(buildTemp.FullName, true);
}
if (!Directory.Exists(buildTemp.FullName))
Directory.CreateDirectory(buildTemp.FullName);
// Generally, the builds should happen with a clean source tree,
// but this can be disabled for debugging
if (gitStashPush)
await ProcessHelper.Execute(new[] { "git", "stash", "save", $"auto-build-{releaseInfo.Timestamp:yyyy-MM-dd}" }, workingDirectory: baseDir);
// Inject various files that will be embedded into the build artifacts
await PrepareSourceDirectory(baseDir, releaseInfo, updateUrls);
// Perform the main compilations
await Compile.BuildProjects(baseDir, buildTemp.FullName, sourceProjects, windowsOnly, GUIProjects, buildTargets, releaseInfo, keepBuilds, rtcfg);
// Create the packages
await CreatePackage.BuildPackages(baseDir, buildTemp.FullName, buildTargets, keepBuilds, rtcfg);
Console.WriteLine("Build completed, uploading packages ...");
Console.WriteLine("Upload completed, releasing packages ...");
Console.WriteLine("Release completed, posting release notes ...");
// Clean up the source tree
await ProcessHelper.Execute(new[] {
"git", "checkout",
"Duplicati/License/VersionTag.txt",
"Duplicati/Library/AutoUpdater/AutoUpdateURL.txt",
"Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt",
"Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt"
}, workingDirectory: baseDir);
if (gitStashPush)
await GitPush.TagAndPush(baseDir, releaseInfo);
Console.WriteLine("All done");
}, buildTargetOption, buildTempOption, solutionFileOption, gitStashPushOption, releaseTypeOption, updateUrlsOption, keepBuildsOption);
return command;
}
/// <summary>
/// Updates the source directory prior to building
/// </summary>
/// <param name="baseDir">The source folder base</param>
/// <param name="releaseInfo">The release info to use</param>
/// <param name="updateUrls">The urls to check for updates<param>
/// <returns>An awaitable task</returns>
static Task PrepareSourceDirectory(string baseDir, ReleaseInfo releaseInfo, string updateUrls)
{
updateUrls = updateUrls
.Replace("${RELEASE_TYPE}", releaseInfo.Type.ToString().ToLowerInvariant())
.Replace("${RELEASE_VERSION}", releaseInfo.Version.ToString())
.Replace("${RELEASE_TIMESTAMP}", releaseInfo.Timestamp.ToString("yyyy-MM-dd"));
File.WriteAllText(Path.Combine(baseDir, "Duplicati", "License", "VersionTag.txt"), releaseInfo.Version.ToString());
File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateBuildChannel.txt"), releaseInfo.Type.ToString().ToLowerInvariant());
File.WriteAllText(Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateURL.txt"), updateUrls);
File.Copy(
Path.Combine(baseDir, "Updates", "release_key.txt"),
Path.Combine(baseDir, "Duplicati", "Library", "AutoUpdater", "AutoUpdateSignKey.txt"),
true
);
return Task.CompletedTask;
}
}