2026-10-15 01:14:44 +02:00
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 ();
2026-10-15 09:22:16 +02:00
if ( words . Count == 0 || words . Any ( w => w != "all" && w != "notes" && ! targetNames . Contains ( w )))
2026-10-15 01:14:44 +02:00
{
2026-10-15 09:22:16 +02:00
Console . Error . WriteLine ( "usage: tools/release.sh <all|linux|windows|macos|android|server|notes>... [--commit <sha>]" );
2026-10-15 01:14:44 +02:00
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}" );
2026-10-15 09:22:16 +02:00
if ( targets . Count == 0 )
{
WriteNotes ();
Console . WriteLine ( File . ReadAllText ( Path . Combine ( root , "artifacts" , "release-notes.md" )));
return 0 ;
}
2026-10-15 01:14:44 +02:00
var failed = false ;
2026-10-15 04:39:03 +02:00
if ( targets . Any ( t => t != "server" ))
{
Console . WriteLine ( "== knowledge" );
RunTool ( "knowledge.cs" );
}
2026-10-15 01:14:44 +02:00
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 ();
2026-10-15 04:39:03 +02:00
WriteNotes ();
2026-10-15 01:14:44 +02:00
return failed ? 1 : 0 ;
2026-10-15 04:39:03 +02:00
void WriteNotes ()
{
var source = Path . Combine ( root , "artifacts" , "knowledge" , "SOURCE.txt" );
2026-10-15 09:22:16 +02:00
var lines = Changes ();
lines . AddRange (
[
2026-10-15 04:39:03 +02:00
$"me-web? {version}, commit {commit}, built {builtAt}.",
"",
"Built with the provider map from iptoasn.com (PDDL 1.0):",
"",
2026-10-15 09:22:16 +02:00
] );
2026-10-15 04:39:03 +02:00
lines . AddRange ( File . Exists ( source ) ? File . ReadAllLines ( source ). Where ( l => l . Length > 0 ). Select ( l => $" {l}" ) : [ " (no provider map 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 );
}
2026-10-15 09:22:16 +02:00
List < string > 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 < string >();
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 ];
}
2026-10-15 01:14:44 +02:00
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 < https : //arasaka.software/contact>
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 / 512 x512 / 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 )( 64 bit )
- libstdc ++. so . 6 ( GLIBCXX_3 . 4.22 )( 64 bit )
- libgcc_s . so . 1 ()( 64 bit )
- libfontconfig . so . 1 ()( 64 bit )
- libX11 . so . 6 ()( 64 bit )
- libICE . so . 6 ()( 64 bit )
- libSM . so . 6 ()( 64 bit )
- libssl . so . 3 ()( 64 bit )
- libcrypto . so . 3 ()( 64 bit )
- 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" );
2026-10-15 01:21:43 +02:00
EnsureWorkload ( "android" );
2026-10-15 01:14:44 +02:00
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" );
2026-10-15 01:24:11 +02:00
if (! installed . Split ( '\n' ). Any ( l => l . Trim (). StartsWith ( name + " " , StringComparison . Ordinal ))) Run ( "dotnet" , "workload" , "install" , name );
2026-10-15 01:14:44 +02:00
}
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)" );
}