Refactor build code into steps

This commit is contained in:
Kenneth Skovhede
2024-03-22 14:24:16 +01:00
parent b4aad97697
commit 3c65db405a
7 changed files with 776 additions and 645 deletions
+3 -1
View File
@@ -19,7 +19,9 @@
"--targets", "osx-x64-gui.dmg",
"--targets", "osx-arm64-gui.dmg",
"--targets", "osx-x64-gui.pkg",
"--targets", "osx-arm64-gui.pkg",
"--targets", "osx-arm64-gui.pkg",
"--targets", "linux-x64-gui.deb",
"--targets", "linux-x64-cli.deb",
"--keep-build", "true"
],
"env": {
@@ -0,0 +1,282 @@
using System.Text.RegularExpressions;
namespace ReleaseBuilder.CliCommand;
public static partial class Build
{
/// <summary>
/// Helper methods cleaning and signing build outputs
/// </summary>
private static class PostCompile
{
/// <summary>
/// Prepares a target directory with fixes that are done post-build, but before making the individual packages
/// </summary>
/// <param name="baseDir">The source directory</param>
/// <param name="buildDir">The output build directory to modify</param>
/// <param name="os">The target operating system</param>
/// <param name="arch">The target architecture</param>
/// <param name="rtcfg">The runtime config</param>
/// <param name="keepBuilds">A flag that allows re-using existing builds</param>
/// <returns>An awaitable task</returns>
public static async Task PrepareTargetDirectory(string baseDir, string buildDir, OSType os, ArchType arch, RuntimeConfig rtcfg, bool keepBuilds)
{
await RemoveUnwantedFiles(os, buildDir);
switch (os)
{
case OSType.Windows:
await SignWindowsExecutables(buildDir, rtcfg);
break;
case OSType.MacOS:
await SetExecutableFlags(buildDir, rtcfg);
await MakeSymlinks(buildDir);
await BundleMacOSApplication(baseDir, buildDir, rtcfg, keepBuilds);
break;
case OSType.Linux:
await SetExecutableFlags(buildDir, rtcfg);
await MakeSymlinks(buildDir);
break;
default:
break;
}
}
/// <summary>
/// A list of folders that are unwanted for a given OS target
/// </summary>
/// <param name="os">The OS to get unwanted folders for</param>
/// <returns>The unwanted folders</returns>
static string[] UnwantedFolders(OSType os)
=> os switch
{
OSType.Windows => ["lvm-scripts"],
OSType.MacOS => ["lvm-scripts", "win-tools"],
OSType.Linux => ["win-tools"],
_ => throw new Exception($"Not supported os: {os}")
};
/// <summary>
/// A list of files that are unwanted for a given OS target
/// </summary>
/// <param name="os">The OS to get unwanted files for</param>
/// <returns>The files that are unwanted</returns>
static string[] UnwantedFiles(OSType os)
=> os switch
{
OSType.Windows => [],
OSType.MacOS => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")],
OSType.Linux => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")],
_ => throw new Exception($"Not supported os: {os}")
};
/// <summary>
/// The unwanted filenames
/// </summary>
/// <param name="os">The operating system to get the unwanted filenames for</param>
/// <returns>The list of unwanted filenames</returns>
static IEnumerable<string> UnwantedFileGlobExps(OSType os)
=> new[] {
"Thumbs.db",
"desktop.ini",
".DS_Store",
"*.bak",
"*.pdb",
"*.mdb",
"._*",
os == OSType.Windows ? "*.sh" : "*.bat"
};
/// <summary>
/// Returns a regular expression mapping files that are not wanted in the build folders
/// </summary>
/// <param name="os">The operating system to get the unwanted filenames for</param>
/// <returns>A regular expression for matching unwanted filenames</returns>
static Regex UnwantedFilePatterns(OSType os)
=> new Regex(@$"^({string.Join("|", UnwantedFileGlobExps(os).Select(x => x.Replace(".", "\\.").Replace("*", ".*")))})$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Removes unwanted contents from the build folders
/// </summary>
/// <param name="os">The operating system the folder is targeting</param>
/// <param name="buildDir">The directory where the build output is placed</param>
/// <returns>An awaitable task</returns>
static Task RemoveUnwantedFiles(OSType os, string buildDir)
{
foreach (var d in UnwantedFolders(os).Select(x => Path.Combine(buildDir, x)))
if (Directory.Exists(d))
Directory.Delete(d, true);
foreach (var f in UnwantedFiles(os).Select(x => Path.Combine(buildDir, x)))
if (File.Exists(f))
File.Delete(f);
var patterns = UnwantedFilePatterns(os);
foreach (var f in Directory.EnumerateFiles(buildDir, "*", SearchOption.AllDirectories).Where(x => patterns.IsMatch(Path.GetFileName(x))))
if (File.Exists(f))
File.Delete(f);
return Task.CompletedTask;
}
/// <summary>
/// Introduces symbolic links for executables that have a different name
/// </summary>
/// <param name="buildDir">The build path to use</param>
/// <returns>An awaitable task</returns>
static Task MakeSymlinks(string buildDir)
{
foreach (var k in ExecutableRenames)
if (File.Exists(Path.Combine(buildDir, k.Key)) && !File.Exists(Path.Combine(buildDir, k.Value)))
File.CreateSymbolicLink(Path.Combine(buildDir, k.Value), Path.Combine(".", k.Key));
return Task.CompletedTask;
}
/// <summary>
/// Sets the executable flags for the build output
/// </summary>
/// <param name="buildDir">The build directory</param>
/// <param name="rtcfg">The runtime configuration</param>
/// <returns>An awaitable task</returns>
static Task SetExecutableFlags(string buildDir, RuntimeConfig rtcfg)
{
if (!OperatingSystem.IsWindows())
{
// Mark executables with the execute flag
var executables = rtcfg.ExecutableBinaries.Select(x => Path.Combine(buildDir, x))
.Concat(Directory.EnumerateFiles(buildDir, "*.sh", SearchOption.AllDirectories));
var filemode = EnvHelper.GetUnixFileMode("+x");
foreach (var x in executables)
if (File.Exists(x))
EnvHelper.AddFilemode(x, filemode);
}
return Task.CompletedTask;
}
/// <summary>
/// Creates the MacOS folder structure by moving all files into a .app folder
/// </summary>
/// <param name="baseDir">The source folder</param>
/// <param name="buildDir">The MacOS build output</param>
/// <param name="rtcfg">The runtime configuration</param>
/// <param name="keepBuilds">A flag that allows re-using existing builds</param>
/// <returns>An awaitable task</returns>
static async Task BundleMacOSApplication(string baseDir, string buildDir, RuntimeConfig rtcfg, bool keepBuilds)
{
var buildroot = Path.GetDirectoryName(buildDir) ?? throw new Exception("Bad build dir");
// Create target .app folder
var appDir = Path.Combine(
buildroot,
$"{Path.GetFileName(buildDir)}-{MacOSAppName}"
);
if (Directory.Exists(appDir))
{
if (keepBuilds)
{
Console.WriteLine("App folder already exsists, skipping MacOS application build");
return;
}
Directory.Delete(appDir, true);
}
// Prepare the .app folder structure
var tmpApp = Path.Combine(buildroot, "tmpapp", MacOSAppName);
var folders = new[] {
Path.Combine("Contents"),
Path.Combine("Contents", "MacOS"),
Path.Combine("Contents", "Resources"),
};
if (Directory.Exists(tmpApp))
Directory.Delete(tmpApp, true);
Directory.CreateDirectory(tmpApp);
foreach (var f in folders)
Directory.CreateDirectory(Path.Combine(tmpApp, f));
// Copy the primary contents into the binary folder
var binDir = Path.Combine(tmpApp, "Contents", "MacOS");
EnvHelper.CopyDirectory(buildDir, binDir, recursive: true);
// Patch the plist and place the icon from the resources
var installerDir = Path.Combine(baseDir, "Installer", "MacOS");
var plist = File.ReadAllText(Path.Combine(installerDir, "app-resources", "Info.plist"))
.Replace("!LONG_VERSION!", rtcfg.ReleaseInfo.ReleaseName)
.Replace("!SHORT_VERSION!", rtcfg.ReleaseInfo.Version.ToString());
File.WriteAllText(
Path.Combine(tmpApp, "Contents", "Info.plist"),
plist
);
File.Copy(
Path.Combine(installerDir, "app-resources", "Duplicati.icns"),
Path.Combine(tmpApp, "Contents", "Resources", "Duplicati.icns"),
overwrite: true
);
// Inject the launch agent
EnvHelper.CopyDirectory(
Path.Combine(installerDir, "daemon"),
Path.Combine(tmpApp, "Contents", "Resources"),
recursive: true
);
// Inject the uninstall.sh script
File.Copy(
Path.Combine(installerDir, "uninstall.sh"),
Path.Combine(tmpApp, "Contents", "MacOS", "uninstall.sh"),
overwrite: true
);
if (rtcfg.UseCodeSignSigning)
{
var entitlementFile = Path.Combine(installerDir, "Entitlements.plist");
foreach (var f in Directory.EnumerateFiles(binDir, "*", SearchOption.AllDirectories))
await rtcfg.Codesign(f, entitlementFile);
await rtcfg.Codesign(Path.Combine(tmpApp), entitlementFile);
}
if (!OperatingSystem.IsWindows())
foreach (var f in Directory.EnumerateFiles(binDir, "*.launchagent.plist", SearchOption.TopDirectoryOnly))
File.SetUnixFileMode(f, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
Directory.Move(tmpApp, appDir);
Directory.Delete(Path.GetDirectoryName(tmpApp) ?? throw new Exception("Unexpected empty path"));
}
/// <summary>
/// Signs all .exe and .dll files with Authenticode
/// </summary>
/// <param name="buildDir">The folder to sign files in</param>
/// <param name="rtcfg">The runtime config</param>
/// <returns>An awaitable task</returns>
static async Task SignWindowsExecutables(string buildDir, RuntimeConfig rtcfg)
{
var cfg = Program.Configuration;
if (!rtcfg.UseAuthenticodeSigning)
return;
var filenames = Directory.EnumerateFiles(buildDir, "Duplicati.*", SearchOption.TopDirectoryOnly)
.Where(x => x.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
.ToList();
Console.WriteLine($"Performing Authenticode signing of {filenames.Count} files");
foreach (var file in filenames)
await rtcfg.AuthenticodeSign(file);
}
}
}
@@ -0,0 +1,89 @@
namespace ReleaseBuilder.CliCommand;
public static partial class Build
{
/// <summary>
/// Main compilation of projects
/// </summary>
private static class Compile
{
/// <summary>
/// Builds the projects listed in <paramref name="sourceProjects"/> for the distinct <paramref name="buildTargets"/>
/// </summary>
/// <param name="baseDir">The base solution folder</param>
/// <param name="buildDir">The folder where builds should be placed</param>
/// <param name="sourceProjects">The projects to build</param>
/// <param name="windowsOnlyProjects">Projects that are only for the Windows targets</param>
/// <param name="guiProjects">Projects that are only needed for GUI builds</param>
/// <param name="buildTargets">The targets to build</param>
/// <param name="releaseInfo">The release info to use for the build</param>
/// <param name="keepBuilds">A flag that allows re-using existing builds</param>
/// <param name="rtcfg">The runtime configuration</param>
/// <returns>A task that completes when the build is done</returns>
public static async Task BuildProjects(string baseDir, string buildDir, IEnumerable<string> sourceProjects, IEnumerable<string> windowsOnlyProjects, IEnumerable<string> guiProjects, IEnumerable<PackageTarget> buildTargets, ReleaseInfo releaseInfo, bool keepBuilds, RuntimeConfig rtcfg)
{
// For tracing, create a log folder and store all logs there
var logFolder = Path.Combine(buildDir, "logs");
Directory.CreateDirectory(logFolder);
// Get the unique build targets (ignoring the package type)
var buildArchTargets = buildTargets.DistinctBy(x => (x.OS, x.Arch, x.Interface)).ToArray();
if (buildArchTargets.Length == 1)
Console.WriteLine($"Building single release: {buildArchTargets.First().BuildTargetString}");
else
Console.WriteLine($"Building {buildArchTargets.Length} versions");
foreach (var target in buildArchTargets)
{
var outputFolder = Path.Combine(buildDir, target.BuildTargetString);
// Faster iteration for debugging is to keep the build folder
if (keepBuilds && Directory.Exists(outputFolder))
{
Console.WriteLine($"Skipping build as output exists for {target.BuildTargetString}");
}
else
{
var tmpfolder = Path.Combine(buildDir, target.BuildTargetString + "-tmp");
Console.WriteLine($"Building {target.BuildTargetString} ...");
foreach (var proj in sourceProjects)
{
if (target.OS != OSType.Windows && windowsOnlyProjects.Contains(proj))
continue;
if (target.Interface == InterfaceType.Cli && guiProjects.Contains(proj))
continue;
// TODO: Creating multiple self-contained binaries really bloats the build size.
//
// One workaround could be to have a single commandline entry project that
// uses the invoked command name to determine the actual command to run
// Similar to how busy-box bundles multiple commands into a single binary
//
// Alternative is to require the .NET runtime to be installed
var command = new string[] {
"dotnet", "publish", proj,
"-c", "Release",
"-o", tmpfolder,
"-r", target.BuildArchString,
$"/p:AssemblyVersion={releaseInfo.Version}",
$"/p:Version={releaseInfo.Version}-{releaseInfo.Type}-{releaseInfo.Timestamp:yyyyMMdd}",
"--self-contained", "true"
};
await ProcessHelper.ExecuteWithLog(command, workingDirectory: tmpfolder, logFolder: logFolder, logFilename: (pid, isStdOut) => $"{Path.GetFileNameWithoutExtension(proj)}.{target.BuildTargetString}.{pid}.{(isStdOut ? "stdout" : "stderr")}.log");
}
Directory.Move(tmpfolder, outputFolder);
}
// Perform any post-build steps, cleaning and signing as needed
await PostCompile.PrepareTargetDirectory(baseDir, outputFolder, target.OS, target.Arch, rtcfg, keepBuilds);
Console.WriteLine("Completed!");
}
}
}
}
@@ -0,0 +1,330 @@
using System.IO.Compression;
namespace ReleaseBuilder.CliCommand;
public static partial class Build
{
/// <summary>
/// Implementations for the package builds
/// </summary>
private static class CreatePackage
{
/// <summary>
/// Builds the packages for the specified build targets.
/// </summary>
/// <param name="baseDir">The base directory.</param>
/// <param name="buildRoot">The build root directory.</param>
/// <param name="buildTargets">The build targets.</param>
/// <param name="keepBuilds">A flag indicating whether to keep the build files.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public static async Task BuildPackages(string baseDir, string buildRoot, IEnumerable<PackageTarget> buildTargets, bool keepBuilds, RuntimeConfig rtcfg)
{
var packagesToBuild = buildTargets.Distinct().ToList();
if (packagesToBuild.Count == 1)
Console.WriteLine($"Building single package: {packagesToBuild.First().PackageTargetString}");
else
Console.WriteLine($"Building {packagesToBuild.Count} packages");
foreach (var target in packagesToBuild)
{
Console.WriteLine($"Building {target.PackageTargetString} ...");
await BuildPackage(baseDir, buildRoot, target, rtcfg, keepBuilds);
Console.WriteLine("Completed!");
}
}
/// <summary>
/// Builds the package for the given target
/// </summary>
/// <param name="baseDir">The source folder base</param>
/// <param name="releaseInfo">The release info to use</param>
/// <param name="rtcfg">The runtime configuration</param>
/// <param name="keepBuilds">A flag that allows re-using existing builds</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
static async Task BuildPackage(string baseDir, string buildRoot, PackageTarget target, RuntimeConfig rtcfg, bool keepBuilds)
{
var packageFolder = Path.Combine(buildRoot, "packages");
if (!Directory.Exists(packageFolder))
Directory.CreateDirectory(packageFolder);
var packageFile = Path.Combine(packageFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
if (File.Exists(packageFile))
{
if (keepBuilds)
{
Console.WriteLine($"Package file already exists, skipping package build for {target.PackageTargetString}");
return;
}
File.Delete(packageFile);
}
var tempFile = Path.Combine(packageFolder, $"tmp-{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
if (File.Exists(tempFile))
File.Delete(tempFile);
switch (target.Package)
{
case PackageType.Zip:
await BuildZipPackage(buildRoot, tempFile, target, rtcfg);
break;
case PackageType.MSI:
await BuildMsiPackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
case PackageType.DMG:
await BuildMacDmgPackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
case PackageType.MacPkg:
await BuildMacPkgPackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
case PackageType.Deb:
await BuildDebPackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
// case PackageType.SynologySpk:
// await BuildZipPackage(buildRoot, tempFile, target, rtcfg);
// await SignSynologyPackage(Path.Combine(outputFolder, target.PackageTargetString), rtcfg);
// break;
default:
throw new Exception($"Unsupported package type: {target.Package}");
}
File.Move(tempFile, packageFile);
}
/// <summary>
/// Builds a zip package asynchronously.
/// </summary>
/// <param name="buildRoot">The output folder where the zip package will be created.</param>
/// <param name="zipFile">The zip file to generate.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
static async Task BuildZipPackage(string buildRoot, string zipFile, PackageTarget target, RuntimeConfig rtcfg)
{
if (File.Exists(zipFile))
File.Delete(zipFile);
using (ZipArchive zip = ZipFile.Open(zipFile, ZipArchiveMode.Create))
{
foreach (var f in Directory.EnumerateFiles(Path.Combine(buildRoot, target.BuildTargetString), "*", SearchOption.AllDirectories))
{
var entry = zip.CreateEntry(Path.GetRelativePath(buildRoot, f), CompressionLevel.Optimal);
using (var stream = entry.Open())
using (var file = File.OpenRead(f))
await file.CopyToAsync(stream);
}
}
}
/// <summary>
/// Builds an MSI package asynchronously.
/// </summary>
/// <param name="baseDir">The source base directory.</param>
/// <param name="buildRoot">The root directory of the build.</param>
/// <param name="msiFile">The MSI file to generate.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A task representing the asynchronous operation.</returns>
static async Task BuildMsiPackage(string baseDir, string buildRoot, string msiFile, PackageTarget target, RuntimeConfig rtcfg)
{
var installerDir = Path.Combine(baseDir, "Installer", "Windows");
var binFiles = Path.Combine(installerDir, "binfiles.wxs");
var sourceFiles = Path.Combine(buildRoot, target.BuildTargetString);
if (!sourceFiles.EndsWith(Path.DirectorySeparatorChar))
sourceFiles += Path.DirectorySeparatorChar;
File.WriteAllText(binFiles, WixHeatBuilder.CreateWixFilelist(sourceFiles));
await ProcessHelper.Execute(new[] {
Program.Configuration.Commands.Wix!,
"--define", $"HarvestPath={sourceFiles}",
"--arch", target.ArchString,
"--output", msiFile,
Path.Combine(installerDir, "Shortcuts.wxs"),
binFiles,
Path.Combine(installerDir, "Duplicati.wxs")
}, workingDirectory: buildRoot);
if (rtcfg.UseAuthenticodeSigning)
await rtcfg.AuthenticodeSign(msiFile);
}
/// <summary>
/// Builds a DMG package asynchronously.
/// </summary>
/// <param name="baseDir">The source base directory.</param>
/// <param name="buildRoot">The root directory of the build.</param>
/// <param name="dmgFile">The DMG file to generate.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A task representing the asynchronous operation.</returns>
static async Task BuildMacDmgPackage(string baseDir, string buildRoot, string dmgFile, PackageTarget target, RuntimeConfig rtcfg)
{
var mountDir = Path.Combine(buildRoot, "mount");
if (Directory.Exists(mountDir))
{
await ProcessHelper.Execute([
"hdiutil", "detach", mountDir, "-quiet", "-force",
], workingDirectory: buildRoot, codeIsError: _ => false);
Directory.Delete(mountDir, false);
}
Directory.CreateDirectory(mountDir);
var installerDir = Path.Combine(baseDir, "Installer", "MacOS");
var compressedDmg = Path.Combine(installerDir, "template.dmg.bz2");
if (!File.Exists(compressedDmg))
throw new FileNotFoundException($"Compressed dmg template file not found: {compressedDmg}");
// Remove the bz2
var templateDmg = Path.Combine(buildRoot, Path.GetFileNameWithoutExtension(compressedDmg));
if (File.Exists(templateDmg))
File.Delete(templateDmg);
// Decompress the dmg
using (var fs = File.Create(templateDmg))
await ProcessHelper.ExecuteWithOutput([
"bzip2", "--decompress", "--keep", "--quiet", "--stdout", compressedDmg
], fs, workingDirectory: buildRoot);
if (!File.Exists(templateDmg))
throw new FileNotFoundException($"Decompressed dmg template file not found: {templateDmg}");
await ProcessHelper.ExecuteAll([
["hdiutil", "resize", "-size", "300M", templateDmg],
["hdiutil", "attach", templateDmg, "-noautoopen", "-quiet", "-mountpoint", mountDir]
], workingDirectory: buildRoot);
// Change the dmg name
var dmgname = $"Duplicati {rtcfg.ReleaseInfo.ReleaseName}";
Console.WriteLine($"Setting dmg name to {dmgname}");
await ProcessHelper.Execute([
"diskutil", "quiet", "rename", mountDir, dmgname
], workingDirectory: mountDir);
// Make the Duplicati.app structure, root folder should exist
var appFolder = Path.Combine(mountDir, MacOSAppName);
if (Directory.Exists(appFolder))
Directory.Delete(appFolder, true);
// Place the prepared folder
EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true);
// Set permissions inside DMG file
if (!OperatingSystem.IsWindows())
await EnvHelper.Chown(appFolder, "root", "admin", true);
// Unmount the dmg and compress
await ProcessHelper.ExecuteAll([
["hdiutil", "detach", mountDir, "-quiet", "-force"],
["hdiutil", "convert", templateDmg, "-quiet", "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", dmgFile]
], workingDirectory: buildRoot);
// Clean up
File.Delete(templateDmg);
Directory.Delete(mountDir, false);
if (rtcfg.UseCodeSignSigning)
await rtcfg.Codesign(dmgFile, Path.Combine(installerDir, "Entitlements.plist"));
}
/// <summary>
/// Builds the Mac package asynchronously.
/// </summary>
/// <param name="baseDir">The base directory.</param>
/// <param name="buildRoot">The build root directory.</param>
/// <param name="pkgFile">The package file path.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A task representing the asynchronous operation.</returns>
static async Task BuildMacPkgPackage(string baseDir, string buildRoot, string pkgFile, PackageTarget target, RuntimeConfig rtcfg)
{
var tmpFolder = Path.Combine(buildRoot, "tmp-pkg");
if (Directory.Exists(tmpFolder))
Directory.Delete(tmpFolder, true);
Directory.CreateDirectory(tmpFolder);
var installerDir = Path.Combine(baseDir, "Installer", "MacOS");
var appFolder = Path.Combine(tmpFolder, MacOSAppName);
if (Directory.Exists(appFolder))
Directory.Delete(appFolder, true);
// Place the prepared folder
EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true);
// Copy the source script files
var scripts = new[] { "daemon", "daemon-scripts", "app-scripts" };
// Copy scripts
foreach (var s in scripts)
EnvHelper.CopyDirectory(Path.Combine(installerDir, s), Path.Combine(tmpFolder, s), recursive: true);
// Set permissions
if (!OperatingSystem.IsWindows())
{
await EnvHelper.Chown(appFolder, "root", "admin", true);
foreach (var f in Directory.EnumerateFiles(Path.Combine(tmpFolder, "daemon"), "*.launchagent.plist", SearchOption.AllDirectories))
await EnvHelper.Chown(f, "root", "wheel", false);
var filemode = EnvHelper.GetUnixFileMode("+x");
var allscripts = scripts.Select(x => Path.Combine(tmpFolder, x)).Where(Directory.Exists).SelectMany(x => Directory.EnumerateFiles(x, "*", SearchOption.AllDirectories));
foreach (var x in allscripts)
if (File.Exists(x))
EnvHelper.AddFilemode(x, filemode);
}
var pkgAppFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-DuplicatiApp.pkg");
if (File.Exists(pkgAppFile))
File.Delete(pkgAppFile);
var pkgDaemonFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-DuplicatiDaemon.pkg");
if (File.Exists(pkgDaemonFile))
File.Delete(pkgDaemonFile);
var distributionFile = Path.Combine(tmpFolder, "Distribution.xml");
File.WriteAllText(distributionFile,
File.ReadAllText(Path.Combine(installerDir, "Distribution.xml"))
.Replace("DuplicatiApp.pkg", Path.GetFileName(pkgAppFile))
.Replace("DuplicatiDaemon.pkg", Path.GetFileName(pkgDaemonFile))
);
// Make the pkg files
await ProcessHelper.ExecuteAll([
["pkgbuild", "--analyze", "--root", appFolder, "--install-location", "/Applications/Duplicati.app", "InstallerComponent.plist"],
["pkgbuild", "--scripts", Path.Combine(tmpFolder, "app-scripts"), "--identifier", "com.duplicati.app", "--root", appFolder, "--install-location", "/Applications/Duplicati.app", "--component-plist", "InstallerComponent.plist", pkgAppFile],
["pkgbuild", "--scripts", Path.Combine(tmpFolder, "daemon-scripts"), "--identifier", "com.duplicati.app.daemon", "--root", Path.Combine(tmpFolder, "daemon"), "--install-location", "/Library/LaunchAgents", pkgDaemonFile],
["productbuild", "--distribution", distributionFile, "--package-path", ".", "--resources", ".", pkgFile]
], workingDirectory: tmpFolder);
// Clean up
Directory.Delete(tmpFolder, true);
// Sign the pkg file
if (rtcfg.UseCodeSignSigning)
await rtcfg.Productsign(pkgFile);
}
/// <summary>
/// Builds a DEB package using Docker
/// </summary>
/// <param name="baseDir">The base directory.</param>
/// <param name="buildRoot">The build root directory.</param>
/// <param name="debFile">The DEB file to generate.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A task representing the asynchronous operation.</returns>
static Task BuildDebPackage(string baseDir, string buildRoot, string debFile, PackageTarget target, RuntimeConfig rtcfg)
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,54 @@
namespace ReleaseBuilder.CliCommand;
public static partial class Build
{
/// <summary>
/// Implementation of the git push command
/// </summary>
private static class GitPush
{
/// <summary>
/// Tags the release and pushes it to the repository
/// </summary>
/// <param name="baseDir">The base git dir</param>
/// <param name="releaseInfo">The release info</param>
/// <returns>A task that completes when the push is done</returns>
public static async Task TagAndPush(string baseDir, ReleaseInfo releaseInfo)
{
// Add modified files
await ProcessHelper.Execute(new[] {
"git", "add",
"Updates/build_version.txt",
"changelog.txt"
}, workingDirectory: baseDir);
// Make a commit
await ProcessHelper.Execute(new[] {
"git", "commit",
"-m", $"Version bump to v{releaseInfo.Version}-{releaseInfo.ReleaseName}",
"-m", "You can download this build from: ",
"-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip",
"-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig",
"-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig.asc",
"-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5",
"-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1",
"-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256"
}, workingDirectory: baseDir);
// And tag the release
await ProcessHelper.Execute(new[] {
"git", "tag", $"v{releaseInfo.Version}-{releaseInfo.ReleaseName}",
"-m", "You can download this build from: ",
"-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip",
"-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig",
"-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig.asc",
"-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5",
"-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1",
"-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256"
}, workingDirectory: baseDir);
// The push the release
await ProcessHelper.Execute(new[] { "git", "push", "--tags" }, workingDirectory: baseDir);
}
}
}
+12 -644
View File
@@ -1,13 +1,11 @@
using System.CommandLine;
using System.IO.Compression;
using System.Text.RegularExpressions;
namespace ReleaseBuilder.CliCommand;
/// <summary>
/// The build command implementation
/// </summary>
public static class Build
public static partial class Build
{
/// <summary>
/// The primary project to build for GUI builds
@@ -366,19 +364,15 @@ public static class Build
rtcfg.ToggleAuthenticodeSigning();
rtcfg.ToggleSignCodeSigning();
if (!keepBuilds)
if (!keepBuilds && Directory.Exists(buildTemp.FullName))
{
if (Directory.Exists(buildTemp.FullName))
{
Console.WriteLine($"Deleting build folder: {buildTemp.FullName}");
Directory.Delete(buildTemp.FullName, true);
}
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)
@@ -387,72 +381,11 @@ public static class Build
// Inject various files that will be embedded into the build artifacts
await PrepareSourceDirectory(baseDir, releaseInfo, updateUrls);
// For tracing, create a log folder and store all logs there
var logFolder = Path.Combine(buildTemp.FullName, "logs");
Directory.CreateDirectory(logFolder);
// Perform the main compilations
await Compile.BuildProjects(baseDir, buildTemp.FullName, sourceProjects, windowsOnly, GUIProjects, buildTargets, releaseInfo, keepBuilds, rtcfg);
// Get the unique build targets (ignoring the package type)
var buildArchTargets = buildTargets.DistinctBy(x => (x.OS, x.Arch, x.Interface)).ToArray();
if (buildArchTargets.Length == 1)
Console.WriteLine($"Building single release: {buildArchTargets.First().BuildTargetString}");
else
Console.WriteLine($"Building {buildArchTargets.Length} versions");
foreach (var target in buildArchTargets)
{
var outputFolder = Path.Combine(buildTemp.FullName, target.BuildTargetString);
// Faster iteration for debugging is to keep the build folder
if (keepBuilds && Directory.Exists(outputFolder))
{
Console.WriteLine($"Skipping build as output exists for {target.BuildTargetString}");
}
else
{
var tmpfolder = Path.Combine(buildTemp.FullName, target.BuildTargetString + "-tmp");
Console.WriteLine($"Building {target.BuildTargetString} ...");
foreach (var proj in sourceProjects)
{
if (target.OS != OSType.Windows && windowsOnly.Contains(proj))
continue;
if (target.Interface == InterfaceType.Cli && GUIProjects.Contains(proj))
continue;
var command = new string[] {
"dotnet", "publish", proj,
"-c", "Release",
"-o", tmpfolder,
"-r", target.BuildArchString,
$"/p:AssemblyVersion={releaseInfo.Version}",
$"/p:Version={releaseInfo.Version}-{releaseInfo.Type}-{releaseInfo.Timestamp:yyyyMMdd}",
"--self-contained", "false"
};
await ProcessHelper.ExecuteWithLog(command, workingDirectory: tmpfolder, logFolder: logFolder, logFilename: (pid, isStdOut) => $"{Path.GetFileNameWithoutExtension(proj)}.{target.BuildTargetString}.{pid}.{(isStdOut ? "stdout" : "stderr")}.log");
}
Directory.Move(tmpfolder, outputFolder);
}
await PrepareTargetDirectory(baseDir, outputFolder, target.OS, target.Arch, rtcfg);
Console.WriteLine("Completed!");
}
var packagesToBuild = buildTargets.Distinct().ToList();
if (packagesToBuild.Count == 1)
Console.WriteLine($"Building single package: {packagesToBuild.First().PackageTargetString}");
else
Console.WriteLine($"Building {packagesToBuild.Count} packages");
foreach (var target in packagesToBuild)
{
Console.WriteLine($"Building {target.PackageTargetString} ...");
await BuildPackage(baseDir, buildTemp.FullName, target, rtcfg);
Console.WriteLine("Completed!");
}
// Create the packages
await CreatePackage.BuildPackages(baseDir, buildTemp.FullName, buildTargets, keepBuilds, rtcfg);
Console.WriteLine("Build completed, uploading packages ...");
@@ -460,10 +393,8 @@ public static class Build
Console.WriteLine("Release completed, posting release notes ...");
if (gitStashPush)
{
// Clean up the source tree
await ProcessHelper.Execute(new[] {
// Clean up the source tree
await ProcessHelper.Execute(new[] {
"git", "checkout",
"Duplicati/License/VersionTag.txt",
"Duplicati/Library/AutoUpdater/AutoUpdateURL.txt",
@@ -471,41 +402,8 @@ public static class Build
"Duplicati/Library/AutoUpdater/AutoUpdateBuildChannel.txt"
}, workingDirectory: baseDir);
// Add modified files
await ProcessHelper.Execute(new[] {
"git", "add",
"Updates/build_version.txt",
"changelog.txt"
}, workingDirectory: baseDir);
// Make a commit
await ProcessHelper.Execute(new[] {
"git", "commit",
"-m", $"Version bump to v{releaseInfo.Version}-{releaseInfo.ReleaseName}",
"-m", "You can download this build from: ",
"-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip",
"-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig",
"-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig.asc",
"-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5",
"-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1",
"-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256"
}, workingDirectory: baseDir);
// And tag the release
await ProcessHelper.Execute(new[] {
"git", "tag", $"v{releaseInfo.Version}-{releaseInfo.ReleaseName}",
"-m", "You can download this build from: ",
"-m", $"Binaries: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip",
"-m", $"Signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig",
"-m", $"ASCII signature file: https://updates.duplicati.com/{releaseInfo.Type}/{releaseInfo.ReleaseName}.zip.sig.asc",
"-m", $"MD5: {releaseInfo.ReleaseName}.zip.md5",
"-m", $"SHA1: {releaseInfo.ReleaseName}.zip.sha1",
"-m", $"SHA256: {releaseInfo.ReleaseName}.zip.sha256"
}, workingDirectory: baseDir);
// The push the release
await ProcessHelper.Execute(new[] { "git", "push", "--tags" }, workingDirectory: baseDir);
}
if (gitStashPush)
await GitPush.TagAndPush(baseDir, releaseInfo);
Console.WriteLine("All done");
@@ -514,275 +412,6 @@ public static class Build
return command;
}
/// <summary>
/// Builds the package for the given target
/// </summary>
/// <param name="baseDir">The source folder base</param>
/// <param name="releaseInfo">The release info to use</param>
/// <param name="rtcfg">The runtime configuration</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private static async Task BuildPackage(string baseDir, string buildRoot, PackageTarget target, RuntimeConfig rtcfg)
{
var packageFolder = Path.Combine(buildRoot, "packages");
if (!Directory.Exists(packageFolder))
Directory.CreateDirectory(packageFolder);
var packageFile = Path.Combine(packageFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
if (File.Exists(packageFile))
{
Console.WriteLine($"Package file already exists, skipping package build for {target.PackageTargetString}");
return;
}
var tempFile = Path.Combine(packageFolder, $"tmp-{rtcfg.ReleaseInfo.ReleaseName}-{target.PackageTargetString}");
if (File.Exists(tempFile))
File.Delete(tempFile);
switch (target.Package)
{
case PackageType.Zip:
await BuildZipPackage(buildRoot, tempFile, target, rtcfg);
break;
case PackageType.MSI:
await BuildMsiPackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
case PackageType.DMG:
await BuildMacDmgPackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
case PackageType.MacPkg:
await BuildMacPkgPackage(baseDir, buildRoot, tempFile, target, rtcfg);
break;
// case PackageType.SynologySpk:
// await BuildZipPackage(buildRoot, tempFile, target, rtcfg);
// await SignSynologyPackage(Path.Combine(outputFolder, target.PackageTargetString), rtcfg);
// break;
default:
throw new Exception($"Unsupported package type: {target.Package}");
}
File.Move(tempFile, packageFile);
}
/// <summary>
/// Builds a zip package asynchronously.
/// </summary>
/// <param name="buildRoot">The output folder where the zip package will be created.</param>
/// <param name="zipFile">The zip file to generate.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private static async Task BuildZipPackage(string buildRoot, string zipFile, PackageTarget target, RuntimeConfig rtcfg)
{
if (File.Exists(zipFile))
File.Delete(zipFile);
using (ZipArchive zip = ZipFile.Open(zipFile, ZipArchiveMode.Create))
{
foreach (var f in Directory.EnumerateFiles(Path.Combine(buildRoot, target.BuildTargetString), "*", SearchOption.AllDirectories))
{
var entry = zip.CreateEntry(Path.GetRelativePath(buildRoot, f), CompressionLevel.Optimal);
using (var stream = entry.Open())
using (var file = File.OpenRead(f))
await file.CopyToAsync(stream);
}
}
}
/// <summary>
/// Builds an MSI package asynchronously.
/// </summary>
/// <param name="baseDir">The source base directory.</param>
/// <param name="buildRoot">The root directory of the build.</param>
/// <param name="msiFile">The MSI file to generate.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A task representing the asynchronous operation.</returns>
private static async Task BuildMsiPackage(string baseDir, string buildRoot, string msiFile, PackageTarget target, RuntimeConfig rtcfg)
{
var installerDir = Path.Combine(baseDir, "Installer", "Windows");
var binFiles = Path.Combine(installerDir, "binfiles.wxs");
var sourceFiles = Path.Combine(buildRoot, target.BuildTargetString);
if (!sourceFiles.EndsWith(Path.DirectorySeparatorChar))
sourceFiles += Path.DirectorySeparatorChar;
File.WriteAllText(binFiles, WixHeatBuilder.CreateWixFilelist(sourceFiles));
await ProcessHelper.Execute(new[] {
Program.Configuration.Commands.Wix!,
"--define", $"HarvestPath={sourceFiles}",
"--arch", target.ArchString,
"--output", msiFile,
Path.Combine(installerDir, "Shortcuts.wxs"),
binFiles,
Path.Combine(installerDir, "Duplicati.wxs")
}, workingDirectory: buildRoot);
if (rtcfg.UseAuthenticodeSigning)
await rtcfg.AuthenticodeSign(msiFile);
}
/// <summary>
/// Builds a DMG package asynchronously.
/// </summary>
/// <param name="baseDir">The source base directory.</param>
/// <param name="buildRoot">The root directory of the build.</param>
/// <param name="dmgFile">The DMG file to generate.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A task representing the asynchronous operation.</returns>
private static async Task BuildMacDmgPackage(string baseDir, string buildRoot, string dmgFile, PackageTarget target, RuntimeConfig rtcfg)
{
var mountDir = Path.Combine(buildRoot, "mount");
if (Directory.Exists(mountDir))
{
await ProcessHelper.Execute([
"hdiutil", "detach", mountDir, "-quiet", "-force",
], workingDirectory: buildRoot, codeIsError: _ => false);
Directory.Delete(mountDir, false);
}
Directory.CreateDirectory(mountDir);
var installerDir = Path.Combine(baseDir, "Installer", "MacOS");
var compressedDmg = Path.Combine(installerDir, "template.dmg.bz2");
if (!File.Exists(compressedDmg))
throw new FileNotFoundException($"Compressed dmg template file not found: {compressedDmg}");
// Remove the bz2
var templateDmg = Path.Combine(buildRoot, Path.GetFileNameWithoutExtension(compressedDmg));
if (File.Exists(templateDmg))
File.Delete(templateDmg);
// Decompress the dmg
using (var fs = File.Create(templateDmg))
await ProcessHelper.ExecuteWithOutput([
"bzip2", "--decompress", "--keep", "--quiet", "--stdout", compressedDmg
], fs, workingDirectory: buildRoot);
if (!File.Exists(templateDmg))
throw new FileNotFoundException($"Decompressed dmg template file not found: {templateDmg}");
await ProcessHelper.ExecuteAll([
["hdiutil", "resize", "-size", "300M", templateDmg],
["hdiutil", "attach", templateDmg, "-noautoopen", "-quiet", "-mountpoint", mountDir]
], workingDirectory: buildRoot);
// Change the dmg name
var dmgname = $"Duplicati {rtcfg.ReleaseInfo.ReleaseName}";
Console.WriteLine($"Setting dmg name to {dmgname}");
await ProcessHelper.Execute([
"diskutil", "quiet", "rename", mountDir, dmgname
], workingDirectory: mountDir);
// Make the Duplicati.app structure, root folder should exist
var appFolder = Path.Combine(mountDir, MacOSAppName);
if (Directory.Exists(appFolder))
Directory.Delete(appFolder, true);
// Place the prepared folder
EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true);
// Set permissions inside DMG file
if (!OperatingSystem.IsWindows())
await EnvHelper.Chown(appFolder, "root", "admin", true);
// Unmount the dmg and compress
await ProcessHelper.ExecuteAll([
["hdiutil", "detach", mountDir, "-quiet", "-force"],
["hdiutil", "convert", templateDmg, "-quiet", "-format", "UDZO", "-imagekey", "zlib-level=9", "-o", dmgFile]
], workingDirectory: buildRoot);
// Clean up
File.Delete(templateDmg);
Directory.Delete(mountDir, false);
if (rtcfg.UseCodeSignSigning)
await rtcfg.Codesign(dmgFile, Path.Combine(installerDir, "Entitlements.plist"));
}
/// <summary>
/// Builds the Mac package asynchronously.
/// </summary>
/// <param name="baseDir">The base directory.</param>
/// <param name="buildRoot">The build root directory.</param>
/// <param name="pkgFile">The package file path.</param>
/// <param name="target">The package target.</param>
/// <param name="rtcfg">The runtime configuration.</param>
/// <returns>A task representing the asynchronous operation.</returns>
private static async Task BuildMacPkgPackage(string baseDir, string buildRoot, string pkgFile, PackageTarget target, RuntimeConfig rtcfg)
{
var tmpFolder = Path.Combine(buildRoot, "tmp-pkg");
if (Directory.Exists(tmpFolder))
Directory.Delete(tmpFolder, true);
Directory.CreateDirectory(tmpFolder);
var installerDir = Path.Combine(baseDir, "Installer", "MacOS");
var appFolder = Path.Combine(tmpFolder, MacOSAppName);
if (Directory.Exists(appFolder))
Directory.Delete(appFolder, true);
// Place the prepared folder
EnvHelper.CopyDirectory(Path.Combine(buildRoot, $"{target.BuildTargetString}-{MacOSAppName}"), appFolder, recursive: true);
// Copy the source script files
var scripts = new[] { "daemon", "daemon-scripts", "app-scripts" };
// Copy scripts
foreach (var s in scripts)
EnvHelper.CopyDirectory(Path.Combine(installerDir, s), Path.Combine(tmpFolder, s), recursive: true);
// Set permissions
if (!OperatingSystem.IsWindows())
{
await EnvHelper.Chown(appFolder, "root", "admin", true);
foreach (var f in Directory.EnumerateFiles(Path.Combine(tmpFolder, "daemon"), "*.launchagent.plist", SearchOption.AllDirectories))
await EnvHelper.Chown(f, "root", "wheel", false);
var filemode = EnvHelper.GetUnixFileMode("+x");
var allscripts = scripts.Select(x => Path.Combine(tmpFolder, x)).Where(Directory.Exists).SelectMany(x => Directory.EnumerateFiles(x, "*", SearchOption.AllDirectories));
foreach (var x in allscripts)
if (File.Exists(x))
EnvHelper.AddFilemode(x, filemode);
}
var pkgAppFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-DuplicatiApp.pkg");
if (File.Exists(pkgAppFile))
File.Delete(pkgAppFile);
var pkgDaemonFile = Path.Combine(tmpFolder, $"{rtcfg.ReleaseInfo.ReleaseName}-DuplicatiDaemon.pkg");
if (File.Exists(pkgDaemonFile))
File.Delete(pkgDaemonFile);
var distributionFile = Path.Combine(tmpFolder, "Distribution.xml");
File.WriteAllText(distributionFile,
File.ReadAllText(Path.Combine(installerDir, "Distribution.xml"))
.Replace("DuplicatiApp.pkg", Path.GetFileName(pkgAppFile))
.Replace("DuplicatiDaemon.pkg", Path.GetFileName(pkgDaemonFile))
);
// Make the pkg files
await ProcessHelper.ExecuteAll([
["pkgbuild", "--analyze", "--root", appFolder, "--install-location", "/Applications/Duplicati.app", "InstallerComponent.plist"],
["pkgbuild", "--scripts", Path.Combine(tmpFolder, "app-scripts"), "--identifier", "com.duplicati.app", "--root", appFolder, "--install-location", "/Applications/Duplicati.app", "--component-plist", "InstallerComponent.plist", pkgAppFile],
["pkgbuild", "--scripts", Path.Combine(tmpFolder, "daemon-scripts"), "--identifier", "com.duplicati.app.daemon", "--root", Path.Combine(tmpFolder, "daemon"), "--install-location", "/Library/LaunchAgents", pkgDaemonFile],
["productbuild", "--distribution", distributionFile, "--package-path", ".", "--resources", ".", pkgFile]
], workingDirectory: tmpFolder);
// Clean up
Directory.Delete(tmpFolder, true);
// Sign the pkg file
if (rtcfg.UseCodeSignSigning)
await rtcfg.Productsign(pkgFile);
}
/// <summary>
/// Updates the source directory prior to building
/// </summary>
@@ -808,265 +437,4 @@ public static class Build
return Task.CompletedTask;
}
/// <summary>
/// Prepares a target directory with fixes that are done post-build, but before making the individual packages
/// </summary>
/// <param name="baseDir">The source directory</param>
/// <param name="buildDir">The output build directory to modify</param>
/// <param name="os">The target operating system</param>
/// <param name="arch">The target architecture</param>
/// <param name="rtcfg">The runtime config</param>
/// <returns>An awaitable task</returns>
static async Task PrepareTargetDirectory(string baseDir, string buildDir, OSType os, ArchType arch, RuntimeConfig rtcfg)
{
await RemoveUnwantedFiles(os, buildDir);
switch (os)
{
case OSType.Windows:
await SignWindowsExecutables(buildDir, rtcfg);
break;
case OSType.MacOS:
await MakeSymlinks(buildDir);
await BundleMacOSApplication(baseDir, buildDir, rtcfg);
break;
case OSType.Linux:
await MakeSymlinks(buildDir);
break;
default:
break;
}
}
/// <summary>
/// A list of folders that are unwanted for a given OS target
/// </summary>
/// <param name="os">The OS to get unwanted folders for</param>
/// <returns>The unwanted folders</returns>
static string[] UnwantedFolders(OSType os)
=> os switch
{
OSType.Windows => ["lvm-scripts"],
OSType.MacOS => ["lvm-scripts", "win-tools"],
OSType.Linux => ["win-tools"],
_ => throw new Exception($"Not supported os: {os}")
};
/// <summary>
/// A list of files that are unwanted for a given OS target
/// </summary>
/// <param name="os">The OS to get unwanted files for</param>
/// <returns>The files that are unwanted</returns>
static string[] UnwantedFiles(OSType os)
=> os switch
{
OSType.Windows => [],
OSType.MacOS => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")],
OSType.Linux => [Path.Combine("utility-scripts", "DuplicatiVerify.ps1")],
_ => throw new Exception($"Not supported os: {os}")
};
/// <summary>
/// The unwanted filenames
/// </summary>
/// <param name="os">The operating system to get the unwanted filenames for</param>
/// <returns>The list of unwanted filenames</returns>
static IEnumerable<string> UnwantedFileGlobExps(OSType os)
=> new[] {
"Thumbs.db",
"desktop.ini",
".DS_Store",
"*.bak",
"*.pdb",
"*.mdb",
"._*",
os == OSType.Windows ? "*.sh" : "*.bat"
};
/// <summary>
/// Returns a regular expression mapping files that are not wanted in the build folders
/// </summary>
/// <param name="os">The operating system to get the unwanted filenames for</param>
/// <returns>A regular expression for matching unwanted filenames</returns>
static Regex UnwantedFilePatterns(OSType os)
=> new Regex(@$"^({string.Join("|", UnwantedFileGlobExps(os).Select(x => x.Replace(".", "\\.").Replace("*", ".*")))})$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Removes unwanted contents from the build folders
/// </summary>
/// <param name="os">The operating system the folder is targeting</param>
/// <param name="buildDir">The directory where the build output is placed</param>
/// <returns>An awaitable task</returns>
static Task RemoveUnwantedFiles(OSType os, string buildDir)
{
foreach (var d in UnwantedFolders(os).Select(x => Path.Combine(buildDir, x)))
if (Directory.Exists(d))
Directory.Delete(d, true);
foreach (var f in UnwantedFiles(os).Select(x => Path.Combine(buildDir, x)))
if (File.Exists(f))
File.Delete(f);
var patterns = UnwantedFilePatterns(os);
foreach (var f in Directory.EnumerateFiles(buildDir, "*", SearchOption.AllDirectories).Where(x => patterns.IsMatch(Path.GetFileName(x))))
if (File.Exists(f))
File.Delete(f);
return Task.CompletedTask;
}
/// <summary>
/// Introduces symbolic links for executables that have a different name
/// </summary>
/// <param name="buildDir">The build path to use</param>
/// <returns>An awaitable task</returns>
static Task MakeSymlinks(string buildDir)
{
foreach (var k in ExecutableRenames)
if (File.Exists(Path.Combine(buildDir, k.Key)) && !File.Exists(Path.Combine(buildDir, k.Value)))
File.CreateSymbolicLink(Path.Combine(buildDir, k.Value), Path.Combine(".", k.Key));
return Task.CompletedTask;
}
/// <summary>
/// Sets the executable flags for the build output
/// </summary>
/// <param name="buildDir">The build directory</param>
/// <param name="rtcfg">The runtime configuration</param>
/// <returns>An awaitable task</returns>
static Task SetExecutableFlags(string buildDir, RuntimeConfig rtcfg)
{
if (!OperatingSystem.IsWindows())
{
// Mark executables with the execute flag
var executables = rtcfg.ExecutableBinaries.Select(x => Path.Combine(buildDir, x))
.Concat(Directory.EnumerateFiles(buildDir, "*.sh", SearchOption.AllDirectories));
var filemode = EnvHelper.GetUnixFileMode("+x");
foreach (var x in executables)
if (File.Exists(x))
EnvHelper.AddFilemode(x, filemode);
}
return Task.CompletedTask;
}
/// <summary>
/// Creates the MacOS folder structure by moving all files into a .app folder
/// </summary>
/// <param name="baseDir">The source folder</param>
/// <param name="buildDir">The MacOS build output</param>
/// <param name="rtcfg">The runtime configuration</param>
/// <returns>An awaitable task</returns>
static async Task BundleMacOSApplication(string baseDir, string buildDir, RuntimeConfig rtcfg)
{
var buildroot = Path.GetDirectoryName(buildDir) ?? throw new Exception("Bad build dir");
// Create target .app folder
var appDir = Path.Combine(
buildroot,
$"{Path.GetFileName(buildDir)}-{MacOSAppName}"
);
if (Directory.Exists(appDir))
{
Console.WriteLine("App folder already exsists, skipping MacOS application build");
return;
}
// Prepare the .app folder structure
var tmpApp = Path.Combine(buildroot, "tmpapp", MacOSAppName);
var folders = new[] {
Path.Combine("Contents"),
Path.Combine("Contents", "MacOS"),
Path.Combine("Contents", "Resources"),
};
if (Directory.Exists(tmpApp))
Directory.Delete(tmpApp, true);
Directory.CreateDirectory(tmpApp);
foreach (var f in folders)
Directory.CreateDirectory(Path.Combine(tmpApp, f));
// Copy the primary contents into the binary folder
var binDir = Path.Combine(tmpApp, "Contents", "MacOS");
EnvHelper.CopyDirectory(buildDir, binDir, recursive: true);
// Patch the plist and place the icon from the resources
var installerDir = Path.Combine(baseDir, "Installer", "MacOS");
var plist = File.ReadAllText(Path.Combine(installerDir, "app-resources", "Info.plist"))
.Replace("!LONG_VERSION!", rtcfg.ReleaseInfo.ReleaseName)
.Replace("!SHORT_VERSION!", rtcfg.ReleaseInfo.Version.ToString());
File.WriteAllText(
Path.Combine(tmpApp, "Contents", "Info.plist"),
plist
);
File.Copy(
Path.Combine(installerDir, "app-resources", "Duplicati.icns"),
Path.Combine(tmpApp, "Contents", "Resources", "Duplicati.icns"),
overwrite: true
);
// Inject the launch agent
EnvHelper.CopyDirectory(
Path.Combine(installerDir, "daemon"),
Path.Combine(tmpApp, "Contents", "Resources"),
recursive: true
);
// Inject the uninstall.sh script
File.Copy(
Path.Combine(installerDir, "uninstall.sh"),
Path.Combine(tmpApp, "Contents", "MacOS", "uninstall.sh"),
overwrite: true
);
if (rtcfg.UseCodeSignSigning)
{
var entitlementFile = Path.Combine(installerDir, "Entitlements.plist");
foreach (var f in Directory.EnumerateFiles(binDir, "*", SearchOption.AllDirectories))
await rtcfg.Codesign(f, entitlementFile);
await rtcfg.Codesign(Path.Combine(tmpApp), entitlementFile);
}
if (!OperatingSystem.IsWindows())
foreach (var f in Directory.EnumerateFiles(binDir, "*.launchagent.plist", SearchOption.TopDirectoryOnly))
File.SetUnixFileMode(f, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
Directory.Move(tmpApp, appDir);
Directory.Delete(Path.GetDirectoryName(tmpApp) ?? throw new Exception("Unexpected empty path"));
}
/// <summary>
/// Signs all .exe and .dll files with Authenticode
/// </summary>
/// <param name="buildDir">The folder to sign files in</param>
/// <param name="rtcfg">The runtime config</param>
/// <returns>An awaitable task</returns>
static async Task SignWindowsExecutables(string buildDir, RuntimeConfig rtcfg)
{
var cfg = Program.Configuration;
if (!rtcfg.UseAuthenticodeSigning)
return;
var filenames = Directory.EnumerateFiles(buildDir, "Duplicati.*", SearchOption.TopDirectoryOnly)
.Where(x => x.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
.ToList();
Console.WriteLine($"Performing Authenticode signing of {filenames.Count} files");
foreach (var file in filenames)
await rtcfg.AuthenticodeSign(file);
}
}
+6
View File
@@ -21,12 +21,18 @@ class Program
"linux-x64-gui.zip",
"linux-x64-gui.deb",
"linux-x64-gui.rpm",
"linux-x64-cli.zip",
"linux-x64-cli.deb",
"linux-x64-cli.rpm",
"linux-x64-cli.docker",
// "linux-x64-cli.spk",
"linux-arm64-cli.docker",
"linux-arm64-gui.zip",
"linux-arm64-gui.deb",
"linux-arm64-gui.rpm",
"linux-arm64-cli.zip",
"linux-arm64-cli.deb",
"linux-arm64-cli.rpm",
// "linux-arm64-cli.spk",
"osx-x64-gui.dmg",
"osx-x64-gui.pkg",