using System.Diagnostics; using System.Formats.Tar; using System.IO.Compression; using System.Security.Cryptography; using System.Text.RegularExpressions; using System.Xml.Linq; const string KeystoreVariable = "MEWEB_KEYSTORE"; const string KeystorePasswordVariable = "MEWEB_KEYSTORE_PASS"; const string KeyAlias = "meweb"; string[] targetNames = ["linux", "windows", "macos", "android", "server"]; const UnixFileMode ExecutableMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute; const UnixFileMode RegularMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead; var root = FindRoot(); var version = XDocument.Load(Path.Combine(root, "Directory.Build.props")).Descendants("Version").FirstOrDefault()?.Value.Trim(); if (version is null || !Regex.IsMatch(version, @"^\d+\.\d{1,2}\.\d{1,2}$")) { Console.Error.WriteLine($"invalid Version in Directory.Build.props: '{version}' (expected x.y.z)"); return 2; } var words = args.Where((a, i) => !a.StartsWith("--", StringComparison.Ordinal) && (i == 0 || !args[i - 1].StartsWith("--", StringComparison.Ordinal))) .Select(a => a.ToLowerInvariant()).ToList(); if (words.Count == 0 || words.Any(w => w != "all" && w != "notes" && !targetNames.Contains(w))) { Console.Error.WriteLine("usage: tools/release.sh ... [--commit ]"); return 2; } var targets = words.Contains("all") ? targetNames.ToList() : targetNames.Where(words.Contains).ToList(); var commit = Option("--commit") ?? "dev"; var builtAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); var output = Path.Combine(root, "releases", version); var desktop = Path.Combine(root, "src", "MeWeb.Desktop", "MeWeb.Desktop.csproj"); var android = Path.Combine(root, "src", "MeWeb.Android", "MeWeb.Android.csproj"); var server = Path.Combine(root, "src", "MeWeb.Server", "MeWeb.Server.csproj"); var branding = Path.Combine(root, "branding"); Directory.CreateDirectory(output); Console.WriteLine($"me-web? {version} ({commit}): {string.Join(", ", targets)} -> {output}"); if (targets.Count == 0) { WriteNotes(); Console.WriteLine(File.ReadAllText(Path.Combine(root, "artifacts", "release-notes.md"))); return 0; } var failed = false; if (targets.Any(t => t != "server")) { Console.WriteLine("== knowledge"); RunTool("knowledge.cs"); } foreach (var target in targets) { try { Console.WriteLine($"== {target}"); var detail = target switch { "linux" => Linux(), "windows" => Windows(), "macos" => MacOs(), "android" => Android(), _ => Server(), }; Console.WriteLine($"ok {target}: {detail}"); } catch (Exception ex) { failed = true; Console.Error.WriteLine($"FAIL {target}: {ex.Message}"); } } WriteSums(); WriteNotes(); return failed ? 1 : 0; void WriteNotes() { var source = Path.Combine(root, "artifacts", "knowledge", "SOURCE.txt"); var lines = Changes(); lines.AddRange( [ $"me-web? {version}, commit {commit}, built {builtAt}.", "", "Built with these open network datasets:", "", ]); lines.AddRange(File.Exists(source) ? File.ReadAllLines(source).Select(l => l.Length > 0 ? $" {l}" : "") : [" (no network data in this build)"]); lines.AddRange(["", "SHA256SUMS.txt lists every file. The APK's signing certificate SHA-256 is 5f52cff4f52f4e0b6da6cd044bfceaf78d1d42db24f2470602db7f19cbfdf846."]); Directory.CreateDirectory(Path.Combine(root, "artifacts")); File.WriteAllLines(Path.Combine(root, "artifacts", "release-notes.md"), lines); } List Changes() { var previous = Capture("git", "-C", root, "describe", "--tags", "--abbrev=0", "--match", "v*", "HEAD^").Trim(); var log = Capture("git", "-C", root, "log", "--no-merges", "--reverse", "--format=%x1e%s%x1f%b", previous.Length > 0 ? $"{previous}..HEAD" : "HEAD"); var lines = new List(); foreach (var entry in log.Split('\x1e', StringSplitOptions.RemoveEmptyEntries)) { var parts = entry.Split('\x1f'); var subject = Regex.Replace(parts[0].Trim(), @"^me-web\? [\d.]+:\s*", ""); if (subject.Length == 0) continue; var body = (parts.Length > 1 ? parts[1] : "").Split('\n').Select(l => l.TrimEnd()) .Where(l => !Regex.IsMatch(l, @"^[A-Za-z-]+-(By|Session|Off-By):", RegexOptions.IgnoreCase)) .Where(l => !l.Contains("claude", StringComparison.OrdinalIgnoreCase)) .SkipWhile(l => l.Length == 0).Reverse().SkipWhile(l => l.Length == 0).Reverse().ToList(); lines.AddRange([$"### {char.ToUpperInvariant(subject[0])}{subject[1..]}", ""]); if (body.Count > 0) lines.AddRange([.. body, ""]); } return lines.Count == 0 ? [] : [$"## What's new in {version}", "", .. lines]; } string[] Stamp() => [$"-p:Version={version}", $"-p:BuildCommit={commit}", $"-p:BuildTimeUtc={builtAt}"]; string Linux() { var stage = Reset(Path.Combine(root, "dist", "linux")); var installed = Path.Combine(stage, "app"); Dotnet(["publish", desktop, "-c", "Release", "-r", "linux-x64", "--self-contained", "true", "-p:DebugType=none", "-o", installed, .. Stamp()]); foreach (var required in new[] { "meweb", "libSkiaSharp.so", "libHarfBuzzSharp.so" }) if (!File.Exists(Path.Combine(installed, required))) throw new InvalidOperationException($"{required} missing from the Linux build"); foreach (var junk in Directory.GetFiles(installed, "*.pdb").Append(Path.Combine(installed, "libcoreclrtraceptprovider.so"))) if (File.Exists(junk)) File.Delete(junk); NormaliseModes(installed, ["meweb", "createdump"]); File.WriteAllText(Path.Combine(stage, "software.arasaka.meweb.desktop"), """ [Desktop Entry] Type=Application Name=me-web? Comment=Can you run your own server from here? Comment[it]=Si può ospitare un server da questa connessione? Exec=meweb TryExec=meweb Icon=software.arasaka.meweb Terminal=false Categories=Network;Utility; StartupWMClass=meweb """); File.WriteAllText(Path.Combine(stage, "postinstall.sh"), """ #!/bin/sh if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q -f -t /usr/share/icons/hicolor || true; fi exit 0 """); File.Copy(Path.Combine(stage, "postinstall.sh"), Path.Combine(stage, "postremove.sh"), overwrite: true); var png = Path.Combine(branding, "meweb-512.png"); var svg = Path.Combine(branding, "meweb.svg"); var yaml = Path.Combine(stage, "nfpm.yaml"); File.WriteAllText(yaml, $""" name: meweb arch: amd64 platform: linux version: {version} release: "1" section: net priority: optional maintainer: Arasaka di Hvozdikov Yevheniy vendor: Arasaka di Hvozdikov Yevheniy homepage: https://git.thepra.dev/Arasaka/MeWeb license: AGPL-3.0-only description: | Can you run your own server from here? me-web? checks your connection and device and explains, in plain words, what you could host at home and what stands in the way. contents: - src: {installed} dst: /opt/meweb type: tree - src: /opt/meweb/meweb dst: /usr/bin/meweb type: symlink - src: {stage}/software.arasaka.meweb.desktop dst: /usr/share/applications/software.arasaka.meweb.desktop file_info: mode: 0644 - src: {png} dst: /usr/share/icons/hicolor/512x512/apps/software.arasaka.meweb.png file_info: mode: 0644 - src: {svg} dst: /usr/share/icons/hicolor/scalable/apps/software.arasaka.meweb.svg file_info: mode: 0644 scripts: postinstall: {stage}/postinstall.sh postremove: {stage}/postremove.sh overrides: deb: depends: - libc6 (>= 2.28) - libgcc-s1 - libstdc++6 - libfontconfig1 - libx11-6 - libice6 - libsm6 - libssl3t64 | libssl3 - libicu78 | libicu76 | libicu74 | libicu72 | libicu70 - xdg-utils recommends: - fonts-noto-core - fonts-noto-cjk rpm: depends: - libc.so.6(GLIBC_2.28)(64bit) - libstdc++.so.6(GLIBCXX_3.4.22)(64bit) - libgcc_s.so.1()(64bit) - libfontconfig.so.1()(64bit) - libX11.so.6()(64bit) - libICE.so.6()(64bit) - libSM.so.6()(64bit) - libssl.so.3()(64bit) - libcrypto.so.3()(64bit) - libicu - /usr/bin/xdg-open recommends: - google-noto-sans-fonts - google-noto-sans-cjk-fonts rpm: summary: Can you run your own server from here? compression: zstd deb: compression: zstd """); var nfpm = Path.Combine(root, "tools", "packaging", "linux-x64", "nfpm"); if (!File.Exists(nfpm)) RunTool("setup.cs", "--group", "packaging"); var names = new List(); foreach (var packager in new[] { "deb", "rpm" }) { var name = $"MeWeb-{version}-linux-x64.{packager}"; Run(nfpm, "package", "--config", yaml, "--packager", packager, "--target", Path.Combine(output, name)); names.Add($"{name} ({Size(Path.Combine(output, name))})"); } return string.Join(", ", names); } string Windows() { var stage = Reset(Path.Combine(root, "dist", "windows")); Dotnet(["publish", desktop, "-c", "Release", "-r", "win-x64", "--self-contained", "true", "-p:PublishSingleFile=true", "-p:IncludeNativeLibrariesForSelfExtract=true", "-p:EnableCompressionInSingleFile=true", "-p:DebugType=none", "-o", stage, .. Stamp()]); var exe = Path.Combine(stage, "meweb.exe"); if (!File.Exists(exe)) throw new InvalidOperationException("meweb.exe missing from the Windows build"); var name = $"MeWeb-{version}-windows-x64.exe"; File.Copy(exe, Path.Combine(output, name), overwrite: true); return $"{name} ({Size(Path.Combine(output, name))})"; } string MacOs() { var names = new List(); foreach (var (rid, label) in new[] { ("osx-arm64", "arm64"), ("osx-x64", "x64") }) { var stage = Reset(Path.Combine(root, "dist", "macos-" + label)); var bundle = Path.Combine(stage, "me-web.app"); var contents = Path.Combine(bundle, "Contents"); var binaries = Path.Combine(contents, "MacOS"); var resources = Path.Combine(contents, "Resources"); Directory.CreateDirectory(resources); Dotnet(["publish", desktop, "-c", "Release", "-r", rid, "--self-contained", "true", "-p:DebugType=none", "-o", binaries, .. Stamp()]); if (!File.Exists(Path.Combine(binaries, "meweb"))) throw new InvalidOperationException($"meweb missing from the {rid} build"); foreach (var pdb in Directory.GetFiles(binaries, "*.pdb")) File.Delete(pdb); File.Copy(Path.Combine(branding, "meweb.icns"), Path.Combine(resources, "meweb.icns"), overwrite: true); File.WriteAllText(Path.Combine(contents, "Info.plist"), $""" CFBundleNameme-web? CFBundleDisplayNameme-web? CFBundleIdentifiersoftware.arasaka.meweb CFBundleVersion{version} CFBundleShortVersionString{version} CFBundleExecutablemeweb CFBundleIconFilemeweb.icns CFBundlePackageTypeAPPL LSMinimumSystemVersion13.0 NSHighResolutionCapable NSLocalNetworkUsageDescriptionme-web? looks for your router to see whether it can open a port for a test. """); NormaliseModes(binaries, ["meweb", "createdump"]); var name = $"MeWeb-{version}-macos-{label}.zip"; var zip = Path.Combine(output, name); if (File.Exists(zip)) File.Delete(zip); RunIn(stage, "zip", "-q", "-r", "-y", zip, "me-web.app"); names.Add($"{name} ({Size(zip)})"); } return string.Join(", ", names); } string Android() { var keystore = Environment.GetEnvironmentVariable(KeystoreVariable); if (string.IsNullOrEmpty(keystore) || !File.Exists(keystore) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable(KeystorePasswordVariable))) throw new InvalidOperationException($"{KeystoreVariable} (a keystore file) and {KeystorePasswordVariable} must be set to sign the APK"); EnsureWorkload("android"); var jdk = Jdk(); var sdk = AndroidSdk(jdk); var stage = Reset(Path.Combine(root, "dist", "android")); Dotnet(["publish", android, "-c", "Release", "-f", "net10.0-android", "-o", stage, $"-p:JavaSdkDirectory={jdk}", $"-p:AndroidSdkDirectory={sdk}", "-p:AndroidKeyStore=true", $"-p:AndroidSigningKeyStore={keystore}", $"-p:AndroidSigningKeyAlias={KeyAlias}", $"-p:AndroidSigningStorePass=env:{KeystorePasswordVariable}", $"-p:AndroidSigningKeyPass=env:{KeystorePasswordVariable}", .. Stamp()]); var apk = Directory.GetFiles(stage, "*-Signed.apk").SingleOrDefault() ?? throw new InvalidOperationException("signed APK not found"); var name = $"MeWeb-{version}-android.apk"; File.Copy(apk, Path.Combine(output, name), overwrite: true); return $"{name} ({Size(Path.Combine(output, name))})"; } string Server() { var stage = Reset(Path.Combine(root, "dist", "server")); Dotnet(["publish", server, "-c", "Release", "-r", "linux-x64", "--self-contained", "true", "-p:DebugType=none", "-o", stage, .. Stamp()]); if (!File.Exists(Path.Combine(stage, "MeWeb.Server"))) throw new InvalidOperationException("MeWeb.Server missing from the server build"); NormaliseModes(stage, ["MeWeb.Server", "createdump"]); var name = $"MeWeb-Server-{version}-linux-x64.tar.gz"; var archive = Path.Combine(output, name); using (var file = File.Create(archive)) using (var gzip = new GZipStream(file, CompressionLevel.Optimal)) TarFile.CreateFromDirectory(stage, gzip, includeBaseDirectory: false); return $"{name} ({Size(archive)})"; } string Jdk() { var candidate = Directory.Exists(Path.Combine(root, "tools", "jdk", "linux-x64")) ? Directory.GetDirectories(Path.Combine(root, "tools", "jdk", "linux-x64")).FirstOrDefault(d => File.Exists(Path.Combine(d, "bin", "javac"))) : null; if (candidate is not null) return candidate; RunTool("setup.cs", "--group", "jdk"); return Directory.GetDirectories(Path.Combine(root, "tools", "jdk", "linux-x64")).First(d => File.Exists(Path.Combine(d, "bin", "javac"))); } string AndroidSdk(string jdk) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); string[] candidates = [Environment.GetEnvironmentVariable("ANDROID_HOME"), Path.Combine(home, "Android", "Sdk"), Path.Combine(root, "tools", "android-sdk")]; var sdk = candidates.FirstOrDefault(c => !string.IsNullOrEmpty(c) && Directory.Exists(Path.Combine(c, "platforms", "android-36"))) ?? Path.Combine(root, "tools", "android-sdk"); if (!Directory.Exists(Path.Combine(sdk, "platforms", "android-36")) || !Directory.Exists(Path.Combine(sdk, "build-tools"))) Dotnet(["build", android, "-t:InstallAndroidDependencies", "-f", "net10.0-android", $"-p:AndroidSdkDirectory={sdk}", $"-p:JavaSdkDirectory={jdk}", "-p:AcceptAndroidSDKLicenses=True"]); return sdk; } void EnsureWorkload(string name) { var installed = Capture("dotnet", "workload", "list"); if (!installed.Split('\n').Any(l => l.Trim().StartsWith(name + " ", StringComparison.Ordinal))) Run("dotnet", "workload", "install", name); } void WriteSums() { var lines = Directory.GetFiles(output).Where(f => !f.EndsWith("SHA256SUMS.txt", StringComparison.Ordinal)).OrderBy(f => f, StringComparer.Ordinal) .Select(f => $"{Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(f)))} {Path.GetFileName(f)}"); File.WriteAllLines(Path.Combine(output, "SHA256SUMS.txt"), lines); } void NormaliseModes(string folder, string[] executables) { if (OperatingSystem.IsWindows()) return; foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories)) File.SetUnixFileMode(file, executables.Contains(Path.GetFileName(file)) ? ExecutableMode : RegularMode); foreach (var dir in Directory.EnumerateDirectories(folder, "*", SearchOption.AllDirectories)) File.SetUnixFileMode(dir, ExecutableMode); } string Reset(string folder) { if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); Directory.CreateDirectory(folder); return folder; } string Size(string file) => $"{new FileInfo(file).Length / 1024.0 / 1024.0:0.0} MB"; string Option(string key) { var i = Array.IndexOf(args, key); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; } void Dotnet(string[] arguments) => Run("dotnet", [.. arguments, "-nologo", "-nodeReuse:false"]); void RunTool(string script, params string[] arguments) => RunIn(root, "dotnet", ["run", Path.Combine("tools", script), "--", .. arguments]); void Run(string file, params string[] arguments) => RunIn(root, file, arguments); void RunIn(string directory, string file, params string[] arguments) { var info = new ProcessStartInfo(file) { UseShellExecute = false, WorkingDirectory = directory }; foreach (var argument in arguments) info.ArgumentList.Add(argument); using var process = Process.Start(info) ?? throw new InvalidOperationException($"cannot start {file}"); process.WaitForExit(); if (process.ExitCode != 0) throw new InvalidOperationException($"{file} {string.Join(' ', arguments.Take(2))} exited with code {process.ExitCode}"); } string Capture(string file, params string[] arguments) { var info = new ProcessStartInfo(file) { UseShellExecute = false, RedirectStandardOutput = true }; foreach (var argument in arguments) info.ArgumentList.Add(argument); using var process = Process.Start(info) ?? throw new InvalidOperationException($"cannot start {file}"); var text = process.StandardOutput.ReadToEnd(); process.WaitForExit(); return text; } static string FindRoot() { var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeWeb.slnx"))) dir = dir.Parent; return dir?.FullName ?? throw new InvalidOperationException("run from inside the repository (MeWeb.slnx not found)"); }