Files
MeWeb/tools/release.cs
T
thepraandClaude Opus 5.5 ad33779356
Build / Desktop, server and tools (push) Successful in 1m12s
Release notes come from app-stores' changelog action, into the deb and rpm too
The release's "What's new" is now written by the shared generator every Arasaka
release uses, from the same commit messages and with the same "me-web? x.y.z:" prefix
removed; the deb and rpm carry it as their changelog (apt changelog, rpm --changelog),
and CHANGELOG.md is attached beside the files and listed in SHA256SUMS.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:19:25 +01:00

407 lines
18 KiB
C#

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 <all|linux|windows|macos|android|server|notes>... [--commit <sha>]");
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 notes = Path.Combine(root, "artifacts", "notes", "notes.md");
var lines = File.Exists(notes) ? [.. File.ReadAllLines(notes), ""] : new List<string>();
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);
}
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
""");
var caches = """
#!/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
""";
var kit = File.ReadAllText(Path.Combine(root, "packaging", "arasaka-repository.sh"));
File.WriteAllText(Path.Combine(stage, "postinstall.sh"), $"{caches}{kit}arasaka_repository_configure \"$@\"\nexit 0\n");
File.WriteAllText(Path.Combine(stage, "postremove.sh"), $"{caches}{kit}arasaka_repository_cleanup \"$@\"\nexit 0\n");
File.WriteAllText(Path.Combine(stage, "arasaka-repository"), "");
var png = Path.Combine(branding, "meweb-512.png");
var svg = Path.Combine(branding, "meweb.svg");
var yaml = Path.Combine(stage, "nfpm.yaml");
var history = Path.Combine(root, "artifacts", "notes", "changelog.yml");
var changelog = File.Exists(history) ? $"changelog: {history}" : "";
File.WriteAllText(yaml, $"""
name: meweb
arch: amd64
platform: linux
version: {version}
release: "1"
section: net
priority: optional
maintainer: Arasaka di Hvozdikov Yevheniy <https://arasaka.software/contact>
vendor: Arasaka di Hvozdikov Yevheniy
homepage: https://git.thepra.dev/Arasaka/MeWeb
license: AGPL-3.0-only
{changelog}
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
- src: {stage}/arasaka-repository
dst: /usr/share/arasaka-repository/meweb
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<string>();
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<string>();
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"), $"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>me-web?</string>
<key>CFBundleDisplayName</key><string>me-web?</string>
<key>CFBundleIdentifier</key><string>software.arasaka.meweb</string>
<key>CFBundleVersion</key><string>{version}</string>
<key>CFBundleShortVersionString</key><string>{version}</string>
<key>CFBundleExecutable</key><string>meweb</string>
<key>CFBundleIconFile</key><string>meweb.icns</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>LSMinimumSystemVersion</key><string>13.0</string>
<key>NSHighResolutionCapable</key><true/>
<key>NSLocalNetworkUsageDescription</key><string>“me-web?” looks for your router to see whether it can open a port for a test.</string>
</dict>
</plist>
""");
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)");
}