mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-11-01 07:36:42 +01:00
936 lines
51 KiB
C#
936 lines
51 KiB
C#
using System.IO.Compression;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace Wayscriber.Tools;
|
|
|
|
internal static class PackagingCommands
|
|
{
|
|
private const string PublicKeyFileName = "WAYSCRIBER-GPG-KEY.asc";
|
|
private const int UnixExitCodeCount = 256;
|
|
private const int UnixPermissionBitsMask = 0x1ff;
|
|
private const int OctalNumberBase = 8;
|
|
private const int UnixModeDigitCount = 4;
|
|
private const int MinimumInstallerManifestBodyLines = 4;
|
|
private const int BytesPerMebibyte = 1024 * 1024;
|
|
private const int MaximumInstallerMebibytes = 1;
|
|
private const int MaximumInstallerBytes = MaximumInstallerMebibytes * BytesPerMebibyte;
|
|
private const int InstallerDownloadTimeoutMinutes = 1;
|
|
private const int NfpmDownloadTimeoutMinutes = 2;
|
|
private const int GpgSecretKeyIdFieldIndex = 4;
|
|
private const string DefaultNfpmVersion = "2.43.4";
|
|
private const string InstallerManifestBeginMarker = "# ARCH_INSTALL_MANIFEST_BEGIN";
|
|
private const string InstallerManifestEndMarker = "# ARCH_INSTALL_MANIFEST_END";
|
|
private const string InstallerManifestFunction = "release_manifest() {";
|
|
private const string InstallerManifestPrint = "printf '%s\\n' \\";
|
|
private const string ReleaseArchiveRootPattern = @"^wayscriber-v\d+\.\d+\.\d+(?:\.\d+)?-linux-x86_64$";
|
|
private const string ReleaseArchivePathPattern = @"^[-A-Za-z0-9._/+]+$";
|
|
private const string ValidServiceCommandPattern = """^ExecStart=(?:")?/usr/bin/wayscriber(?:")? --daemon$""";
|
|
private const string SystemdExecDirectivePattern = @"^\s*Exec[A-Za-z]*=";
|
|
private static readonly int[] ConfiguratorIconSizes = [24, 64, 128];
|
|
private static readonly int[] WayscriberIconSizes = [16, 19, 22, 24, 38, 64, 128];
|
|
|
|
public static IReadOnlyList<ToolCommand> Commands
|
|
{
|
|
get;
|
|
} =
|
|
[
|
|
new( CommandAreas.Package, CommandNames.Build, "Build tar, deb, and rpm artifacts.", SideEffect.FixtureMutating, Build ),
|
|
new( CommandAreas.Package, CommandNames.CheckArchInstaller, "Compare an installer manifest with a tarball.", SideEffect.ReadOnly, CheckArchInstaller ),
|
|
new( CommandAreas.Package, CommandNames.CheckLiveArchInstaller, "Download and check the deployed Arch installer.", SideEffect.ReadOnly, CheckLiveArchInstaller ),
|
|
new( CommandAreas.Package, CommandNames.VerifyArtifacts, "Verify release artifact metadata and layouts.", SideEffect.ReadOnly, VerifyArtifacts ),
|
|
new( CommandAreas.Package, CommandNames.SmokeUbuntu, "Install packages in a disposable Ubuntu container.", SideEffect.MachineMutating, SmokeUbuntu ),
|
|
new( CommandAreas.Package, CommandNames.BuildRepositories, "Build apt and rpm repositories.", SideEffect.FixtureMutating, BuildRepositories ),
|
|
new( CommandAreas.ContinuousIntegration, CommandNames.InstallNfpm, "Install a pinned nfpm binary.", SideEffect.MachineMutating, InstallNfpm ),
|
|
];
|
|
|
|
private static async Task<int> Build( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var version = parsed.TakeOption( CommandLineOptions.Version ) ?? context.Environment( EnvironmentVariables.Version ) ?? VersionCommands.ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) );
|
|
_ = ReleaseVersion.Parse( version );
|
|
var defaultFormats = $"{PackageFormats.Tar},{PackageFormats.Debian},{PackageFormats.Rpm}";
|
|
var formats = (parsed.TakeOption( "--formats" ) ?? context.Environment( EnvironmentVariables.Formats ) ?? defaultFormats)
|
|
.Split( ',', StringSplitOptions.RemoveEmptyEntries );
|
|
var artifactRoot = Path.GetFullPath( parsed.TakeOption( "--artifact-root" ) ?? context.Environment( EnvironmentVariables.ArtifactRoot ) ?? context.Path( "dist" ), context.RepositoryRoot );
|
|
var strip = !parsed.TakeFlag( "--no-strip" );
|
|
_ = parsed.TakeFlag( "--strip" );
|
|
var skipBuild = parsed.TakeFlag( "--skip-build" ) || context.Environment( EnvironmentVariables.SkipBuild ) == EnvironmentVariables.Enabled;
|
|
var configurator = !parsed.TakeFlag( "--no-configurator" ) &&
|
|
context.Environment( EnvironmentVariables.PackageConfigurator ) != EnvironmentVariables.Disabled;
|
|
parsed.RequireEmpty( "package build [--version VERSION] [--formats tar,deb,rpm] [--artifact-root PATH] [--no-strip] [--skip-build] [--no-configurator]" );
|
|
var gtkPrefix = context.Environment( EnvironmentVariables.Gtk4LayerShellPrefix ) ??
|
|
context.Path( RepositoryPaths.TargetDirectory, "release-deps", RepositoryNames.Gtk4LayerShell );
|
|
var environment = new Dictionary<string, string?>
|
|
{
|
|
[EnvironmentVariables.Version] = version,
|
|
[EnvironmentVariables.WayscriberReleaseVersion] = version,
|
|
[EnvironmentVariables.PackageConfigPath] = Path.Combine( gtkPrefix, "lib/pkgconfig" ) + (context.Environment( EnvironmentVariables.PackageConfigPath ) is { Length: > 0 } existing ? $":{existing}" : string.Empty),
|
|
[EnvironmentVariables.SystemGtk4LayerShellLink] = NativeLibraryModes.Static,
|
|
};
|
|
Directory.CreateDirectory( artifactRoot );
|
|
if ( !skipBuild )
|
|
{
|
|
await ToolApplication.RunNestedAsync( context, CommandAreas.Native, CommandNames.InstallGtk4LayerShell,
|
|
["--prefix", gtkPrefix, "--library-mode", NativeLibraryModes.Static] );
|
|
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Locked, CommandLineOptions.Release, CommandLineOptions.Binaries], environment: environment );
|
|
if ( configurator )
|
|
{
|
|
await context.Run( Programs.Cargo, ["build", CommandLineOptions.Locked, CommandLineOptions.Release, CommandLineOptions.Binaries, "--manifest-path", RepositoryPaths.ConfiguratorCargoManifest], environment: environment );
|
|
}
|
|
}
|
|
var mainBinary = context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.MainPackage );
|
|
var configBinary = context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.ConfiguratorPackage );
|
|
await ToolApplication.RunNestedAsync( context, CommandAreas.Elf, CommandNames.VerifyStatic, [mainBinary] );
|
|
await VerifyGlibc( context, mainBinary );
|
|
if ( configurator )
|
|
{
|
|
if ( !File.Exists( configBinary ) )
|
|
{
|
|
throw new ToolException( $"Missing release binary: {configBinary}" );
|
|
}
|
|
await VerifyGlibc( context, configBinary );
|
|
}
|
|
if ( strip )
|
|
{
|
|
await StripBinaries( context, mainBinary, configBinary, configurator );
|
|
}
|
|
|
|
var artifacts = await BuildArtifacts( context, formats, artifactRoot, version, configurator, environment );
|
|
WriteManifest( artifactRoot, version, artifacts );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task StripBinaries( ToolContext context, string mainBinary, string configuratorBinary, bool includeConfigurator )
|
|
{
|
|
var available = await context.Run( Programs.Which, [Programs.Strip], capture: true, trace: false,
|
|
allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
|
|
if ( available.ExitCode != ExitCodes.Success )
|
|
{
|
|
await context.Error.WriteLineAsync( "[WARN] 'strip' not found; binaries will remain unstripped" );
|
|
return;
|
|
}
|
|
|
|
var nonFatalExitCodes = Enumerable.Range( 0, UnixExitCodeCount ).ToHashSet( );
|
|
var mainStrip = await context.Run( Programs.Strip, [mainBinary], allowedExitCodes: nonFatalExitCodes );
|
|
if ( mainStrip.ExitCode != ExitCodes.Success )
|
|
{
|
|
await context.Error.WriteLineAsync( "[WARN] strip failed for wayscriber" );
|
|
}
|
|
if ( includeConfigurator )
|
|
{
|
|
var configuratorStrip = await context.Run( Programs.Strip, [configuratorBinary], allowedExitCodes: nonFatalExitCodes );
|
|
if ( configuratorStrip.ExitCode != ExitCodes.Success )
|
|
{
|
|
await context.Error.WriteLineAsync( "[WARN] strip failed for configurator" );
|
|
}
|
|
}
|
|
|
|
await ToolApplication.RunNestedAsync( context, CommandAreas.Elf, CommandNames.VerifyStatic, [mainBinary] );
|
|
}
|
|
|
|
private static async Task<List<string>> BuildArtifacts( ToolContext context, IEnumerable<string> formats, string artifactRoot,
|
|
string version, bool includeConfigurator, IReadOnlyDictionary<string, string?> environment )
|
|
{
|
|
var artifacts = new List<string>( );
|
|
foreach ( var format in formats )
|
|
{
|
|
if ( format == PackageFormats.Tar )
|
|
{
|
|
artifacts.Add( await BuildTar( context, artifactRoot, version, configurator: false ) );
|
|
if ( includeConfigurator )
|
|
{
|
|
artifacts.Add( await BuildTar( context, artifactRoot, version, configurator: true ) );
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if ( format is PackageFormats.Debian or PackageFormats.Rpm )
|
|
{
|
|
artifacts.Add( await BuildNfpm( context, artifactRoot, format, configurator: false, environment ) );
|
|
if ( includeConfigurator )
|
|
{
|
|
artifacts.Add( await BuildNfpm( context, artifactRoot, format, configurator: true, environment ) );
|
|
}
|
|
continue;
|
|
}
|
|
|
|
await context.Error.WriteLineAsync( $"Unknown format '{format}', skipping" );
|
|
}
|
|
|
|
return artifacts;
|
|
}
|
|
|
|
private static async Task VerifyGlibc( ToolContext context, string binary )
|
|
{
|
|
if ( !File.Exists( binary ) )
|
|
{
|
|
throw new ToolException( $"Missing release binary: {binary}" );
|
|
}
|
|
var result = await context.Run( Programs.ReadElf, ["--version-info", binary], capture: true );
|
|
var versions = Regex.Matches( result.StandardOutput, @"GLIBC_([0-9]+(?:\.[0-9]+)+)" ).Select( match => Version.Parse( match.Groups[1].Value ) ).ToArray( );
|
|
if ( versions.Length == 0 )
|
|
{
|
|
throw new ToolException( $"Could not determine glibc requirement for {binary}" );
|
|
}
|
|
var maximum = versions.Max( )!;
|
|
if ( maximum > Version.Parse( PackagingPlatform.MaximumGlibcVersion ) )
|
|
{
|
|
throw new ToolException( $"{binary} requires GLIBC_{maximum}; release floor is GLIBC_{PackagingPlatform.MaximumGlibcVersion}" );
|
|
}
|
|
await context.Error.WriteLineAsync(
|
|
$"[INFO] Verified {Path.GetFileName( binary )} requires at most GLIBC_{PackagingPlatform.MaximumGlibcVersion} (found GLIBC_{maximum})" );
|
|
}
|
|
|
|
private static async Task<string> BuildTar( ToolContext context, string output, string version, bool configurator )
|
|
{
|
|
var name = configurator ? $"wayscriber-configurator-v{version}-linux-x86_64" : $"wayscriber-v{version}-linux-x86_64";
|
|
var root = Path.Combine( output, name );
|
|
if ( Directory.Exists( root ) )
|
|
{
|
|
Directory.Delete( root, recursive: true );
|
|
}
|
|
Directory.CreateDirectory( root );
|
|
try
|
|
{
|
|
void Copy( string source, string relative, UnixFileMode mode )
|
|
{
|
|
var destination = Path.Combine( root, relative );
|
|
Directory.CreateDirectory( Path.GetDirectoryName( destination )! );
|
|
File.Copy( source, destination, overwrite: true );
|
|
if ( !OperatingSystem.IsWindows( ) )
|
|
{
|
|
File.SetUnixFileMode( destination, mode );
|
|
}
|
|
}
|
|
var regular = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
|
var executable = regular | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
|
|
if ( configurator )
|
|
{
|
|
Copy( context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.ConfiguratorPackage ),
|
|
"usr/bin/wayscriber-configurator", executable );
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "wayscriber-configurator.desktop" ), "usr/share/applications/wayscriber-configurator.desktop", regular );
|
|
foreach ( var size in ConfiguratorIconSizes )
|
|
{
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", $"wayscriber-configurator-{size}.png" ), $"usr/share/icons/hicolor/{size}x{size}/apps/wayscriber-configurator.png", regular );
|
|
}
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-configurator.svg" ), "usr/share/icons/hicolor/scalable/apps/wayscriber-configurator.svg", regular );
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-configurator-128.png" ), "usr/share/pixmaps/wayscriber-configurator.png", regular );
|
|
Copy( context.Path( "README.md" ), "usr/share/doc/wayscriber-configurator/README.md", regular );
|
|
Copy( context.Path( "LICENSE" ), "usr/share/doc/wayscriber-configurator/LICENSE", regular );
|
|
}
|
|
else
|
|
{
|
|
Copy( context.Path( RepositoryPaths.TargetDirectory, RepositoryPaths.ReleaseDirectory, RepositoryNames.MainPackage ),
|
|
"usr/bin/wayscriber", executable );
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, RepositoryNames.UserServiceFile ), "usr/lib/systemd/user/wayscriber.service", regular );
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "wayscriber.desktop" ), "usr/share/applications/wayscriber.desktop", regular );
|
|
foreach ( var size in WayscriberIconSizes )
|
|
{
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", $"wayscriber-{size}.png" ), $"usr/share/icons/hicolor/{size}x{size}/apps/wayscriber.png", regular );
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", $"wayscriber-{size}.png" ), $"usr/share/icons/hicolor/{size}x{size}/status/wayscriber.png", regular );
|
|
}
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber.svg" ), "usr/share/icons/hicolor/scalable/apps/wayscriber.svg", regular );
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-symbolic.svg" ), "usr/share/icons/hicolor/symbolic/apps/wayscriber-symbolic.svg", regular );
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "icons", "wayscriber-128.png" ), "usr/share/pixmaps/wayscriber.png", regular );
|
|
Copy( context.Path( "README.md" ), "usr/share/doc/wayscriber/README.md", regular );
|
|
Copy( context.Path( "config.example.toml" ), "usr/share/doc/wayscriber/config.example.toml", regular );
|
|
Copy( context.Path( "LICENSE" ), "usr/share/doc/wayscriber/LICENSE", regular );
|
|
Copy( context.Path( "LICENSE" ), "usr/share/licenses/wayscriber/LICENSE", regular );
|
|
Copy( context.Path( RepositoryPaths.PackagingDirectory, "licenses", "gtk4-layer-shell.LICENSE" ), "usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell", regular );
|
|
}
|
|
var tarball = Path.Combine( output, name + ".tar.gz" );
|
|
File.Delete( tarball );
|
|
await context.Run( Programs.Tar, [CommandLineOptions.ChangeDirectory, output, "-czf", tarball, name] );
|
|
if ( !File.Exists( tarball ) )
|
|
{
|
|
throw new ToolException( $"tar did not create {tarball}" );
|
|
}
|
|
return tarball;
|
|
}
|
|
finally
|
|
{
|
|
if ( Directory.Exists( root ) )
|
|
{
|
|
Directory.Delete( root, recursive: true );
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task<string> BuildNfpm( ToolContext context, string output, string format, bool configurator,
|
|
IReadOnlyDictionary<string, string?> environment )
|
|
{
|
|
var prefix = configurator ? RepositoryNames.ConfiguratorPackage : RepositoryNames.MainPackage;
|
|
var architecture = format == PackageFormats.Debian ? "amd64" : "x86_64";
|
|
var target = Path.Combine( output, $"{prefix}-{architecture}.{format}" );
|
|
var configVariable = configurator ? EnvironmentVariables.NfpmConfiguratorConfig : EnvironmentVariables.NfpmMainConfig;
|
|
var config = context.Environment( configVariable ) ??
|
|
context.Path( RepositoryPaths.PackagingDirectory, configurator ? "package.configurator.yaml" : "package.wayscriber.yaml" );
|
|
File.Delete( target );
|
|
await context.Run( Programs.Nfpm, ["pkg", "--packager", format, "--config", config, "--target", target], environment: environment );
|
|
if ( !File.Exists( target ) )
|
|
{
|
|
throw new ToolException( $"nfpm did not create {target}" );
|
|
}
|
|
return target;
|
|
}
|
|
|
|
private static void WriteManifest( string output, string version, IReadOnlyList<string> artifacts )
|
|
{
|
|
var entries = artifacts.Select( path => new { name = Path.GetFileName( path ), sha256 = Files.Sha256( path ), size = new FileInfo( path ).Length } ).ToArray( );
|
|
Files.WriteAtomic( Path.Combine( output, "checksums.txt" ), string.Concat( entries.Select( entry => $"{entry.sha256} {entry.name}\n" ) ) );
|
|
Files.WriteAtomic( Path.Combine( output, "manifest.json" ), JsonSerializer.Serialize( new
|
|
{
|
|
version,
|
|
artifacts = entries
|
|
}, new JsonSerializerOptions { WriteIndented = true } ) + "\n" );
|
|
}
|
|
|
|
private static async Task<int> CheckArchInstaller( ToolContext context, string[] args )
|
|
{
|
|
if ( OperatingSystem.IsWindows( ) )
|
|
{
|
|
throw new ToolException( "Arch installer checks require Linux." );
|
|
}
|
|
var parsed = new Arguments( args );
|
|
var installer = Path.GetFullPath( parsed.TakeOption( "--installer", required: true )!, Environment.CurrentDirectory );
|
|
var archive = Path.GetFullPath( parsed.TakeOption( "--archive", required: true )!, Environment.CurrentDirectory );
|
|
parsed.RequireEmpty( "package check-arch-installer --installer FILE --archive FILE" );
|
|
if ( !File.Exists( installer ) || !File.Exists( archive ) )
|
|
{
|
|
throw new ToolException( "Installer or archive was not found." );
|
|
}
|
|
var entries = ParseInstallerManifest( Files.Read( installer ) );
|
|
var listing = await context.Run( Programs.Tar, ["-tzf", archive], capture: true );
|
|
var archiveRoot = ValidateArchiveListing( listing.StandardOutput );
|
|
using var temporary = new TemporaryDirectory( "wayscriber-arch-manifest" );
|
|
await context.Run( Programs.Tar, ["-xzf", archive, CommandLineOptions.ChangeDirectory, temporary.Path] );
|
|
var roots = Directory.GetDirectories( temporary.Path );
|
|
if ( roots.Length != 1 || Path.GetFileName( roots[0] ) != archiveRoot )
|
|
{
|
|
throw new ToolException( "Archive has an unexpected top-level directory." );
|
|
}
|
|
|
|
await ValidateExtractedArchive( context, roots[0] );
|
|
var usr = Path.Combine( roots[0], "usr" );
|
|
if ( !Directory.Exists( usr ) )
|
|
{
|
|
throw new ToolException( "Archive does not contain usr/." );
|
|
}
|
|
|
|
var files = Directory.EnumerateFiles( usr, "*", SearchOption.AllDirectories ).Select( path => Path.GetRelativePath( usr, path ).Replace( Path.DirectorySeparatorChar, '/' ) ).Order( ).ToArray( );
|
|
if ( !entries.Keys.Order( ).SequenceEqual( files ) )
|
|
{
|
|
throw new ToolException( "Installer manifest and release archive file paths differ." );
|
|
}
|
|
foreach ( var pair in entries )
|
|
{
|
|
var path = Path.Combine( usr, pair.Key );
|
|
if ( File.ResolveLinkTarget( path, returnFinalTarget: false ) is not null )
|
|
{
|
|
throw new ToolException( $"Archive contains a symbolic link: {pair.Key}" );
|
|
}
|
|
var actual = Convert.ToString( ( int ) File.GetUnixFileMode( path ) & UnixPermissionBitsMask, OctalNumberBase )!
|
|
.PadLeft( UnixModeDigitCount, '0' );
|
|
if ( actual != pair.Value )
|
|
{
|
|
throw new ToolException( $"mode mismatch for usr/{pair.Key}: expected {pair.Value}, found {actual}" );
|
|
}
|
|
}
|
|
var service = Files.Read( Path.Combine( usr, "lib/systemd/user/wayscriber.service" ) );
|
|
var serviceDirectives = ParseSystemdExecDirectives( service );
|
|
var wayscriberCommands = serviceDirectives.Where( line =>
|
|
line.Contains( RepositoryNames.MainPackage, StringComparison.Ordinal ) ).ToArray( );
|
|
var validCommands = wayscriberCommands.Count( line => Regex.IsMatch( line, ValidServiceCommandPattern ) );
|
|
if ( validCommands != 1 || wayscriberCommands.Length != validCommands )
|
|
{
|
|
throw new ToolException( "Release user service is incompatible with the direct installer rewrite." );
|
|
}
|
|
await context.Output.WriteLineAsync( $"Arch installer manifest matches {Path.GetFileName( archive )}." );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static string[] ParseSystemdExecDirectives( string service )
|
|
{
|
|
var directives = new List<string>( );
|
|
var current = string.Empty;
|
|
foreach ( var physicalLine in service.Replace( "\r\n", "\n", StringComparison.Ordinal ).Split( '\n' ) )
|
|
{
|
|
var line = physicalLine.TrimStart( );
|
|
if ( current.Length > 0 && IsSystemdComment( line ) )
|
|
{
|
|
continue;
|
|
}
|
|
|
|
current += line;
|
|
if ( current.EndsWith( '\\' ) )
|
|
{
|
|
current = current.TrimEnd( '\\' ) + " ";
|
|
continue;
|
|
}
|
|
|
|
if ( Regex.IsMatch( current, SystemdExecDirectivePattern ) )
|
|
{
|
|
directives.Add( current );
|
|
}
|
|
current = string.Empty;
|
|
}
|
|
|
|
if ( Regex.IsMatch( current, SystemdExecDirectivePattern ) )
|
|
{
|
|
directives.Add( current );
|
|
}
|
|
return directives.ToArray( );
|
|
}
|
|
|
|
private static bool IsSystemdComment( string line ) => line.StartsWith( '#' ) || line.StartsWith( ';' );
|
|
|
|
private static string ValidateArchiveListing( string listing )
|
|
{
|
|
var paths = listing.Split( '\n', StringSplitOptions.RemoveEmptyEntries );
|
|
var root = paths.Select( path => path.TrimEnd( '/' ).Split( '/', 2 )[0] ).FirstOrDefault( );
|
|
if ( root is null || !Regex.IsMatch( root, ReleaseArchiveRootPattern ) )
|
|
{
|
|
throw new ToolException( $"Archive has an unexpected top-level directory: {root ?? "<none>"}." );
|
|
}
|
|
|
|
foreach ( var path in paths )
|
|
{
|
|
var isExpected = path == root || path == root + "/" || path == root + "/usr" || path == root + "/usr/" ||
|
|
path.StartsWith( root + "/usr/", StringComparison.Ordinal );
|
|
var isUnsafe = path.StartsWith( "/", StringComparison.Ordinal ) || path.Split( '/' ).Contains( ".." );
|
|
if ( !isExpected || isUnsafe || !Regex.IsMatch( path, ReleaseArchivePathPattern ) )
|
|
{
|
|
throw new ToolException( $"Archive contains an unexpected or unsafe path: {path}" );
|
|
}
|
|
}
|
|
|
|
return root;
|
|
}
|
|
|
|
private static async Task ValidateExtractedArchive( ToolContext context, string root )
|
|
{
|
|
var unsupportedType = await context.Run(
|
|
Programs.Find,
|
|
[root, "!", "-type", "d", "!", "-type", "f", "-print", "-quit"],
|
|
capture: true,
|
|
trace: false );
|
|
if ( unsupportedType.StandardOutput.Length > 0 )
|
|
{
|
|
throw new ToolException( $"Archive contains a symbolic link or special file: {unsupportedType.StandardOutput.Trim( )}" );
|
|
}
|
|
|
|
var hardLink = await context.Run( Programs.Find, [root, "-type", "f", "-links", "+1", "-print", "-quit"], capture: true, trace: false );
|
|
if ( hardLink.StandardOutput.Length > 0 )
|
|
{
|
|
throw new ToolException( $"Archive contains a hard-linked file: {hardLink.StandardOutput.Trim( )}" );
|
|
}
|
|
}
|
|
|
|
internal static Dictionary<string, string> ParseInstallerManifest( string source )
|
|
{
|
|
var lines = source.Replace( "\r\n", "\n", StringComparison.Ordinal ).Split( '\n' );
|
|
if ( lines.Count( line => line == InstallerManifestBeginMarker ) != 1 || lines.Count( line => line == InstallerManifestEndMarker ) != 1 )
|
|
{
|
|
throw new ToolException( "installer must contain one static manifest block" );
|
|
}
|
|
|
|
var beginIndex = Array.IndexOf( lines, InstallerManifestBeginMarker );
|
|
var endIndex = Array.IndexOf( lines, InstallerManifestEndMarker );
|
|
var body = endIndex > beginIndex ? lines[(beginIndex + 1)..endIndex] : [];
|
|
if ( body.Length < MinimumInstallerManifestBodyLines || body[0].Trim( ) != InstallerManifestFunction || body[1].Trim( ) != InstallerManifestPrint ||
|
|
body[^1].Trim( ) != "}" )
|
|
{
|
|
throw new ToolException( "installer manifest block is malformed or uses unsupported syntax" );
|
|
}
|
|
|
|
var result = new Dictionary<string, string>( StringComparer.Ordinal );
|
|
var entries = body[2..^1];
|
|
for ( var index = 0; index < entries.Length; index++ )
|
|
{
|
|
var match = Regex.Match( entries[index], @"^\s*'(?<mode>0[0-7]{3}) (?<path>[-A-Za-z0-9._/+]+)'\s*(?<continuation>\\)?\s*$" );
|
|
var shouldContinue = index < entries.Length - 1;
|
|
if ( !match.Success || match.Groups["continuation"].Success != shouldContinue )
|
|
{
|
|
throw new ToolException( $"unsupported installer manifest syntax: {entries[index].Trim( )}" );
|
|
}
|
|
|
|
var path = match.Groups["path"].Value;
|
|
if ( Path.IsPathRooted( path ) || path.Split( '/' ).Contains( ".." ) || !result.TryAdd( path, match.Groups["mode"].Value ) )
|
|
{
|
|
throw new ToolException( $"unsafe or duplicate manifest path: {path}" );
|
|
}
|
|
}
|
|
|
|
if ( result.Count == 0 )
|
|
{
|
|
throw new ToolException( "installer manifest is empty or malformed" );
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static async Task<int> CheckLiveArchInstaller( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var archive = parsed.TakeOption( "--archive", required: true )!;
|
|
var url = parsed.TakeOption( "--url" ) ?? "https://wayscriber.com/arch-install.sh";
|
|
parsed.RequireEmpty( "package check-live-arch-installer --archive FILE [--url URL]" );
|
|
using var temporary = new TemporaryDirectory( "wayscriber-installer" );
|
|
var installer = Path.Combine( temporary.Path, "arch-install.sh" );
|
|
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes( InstallerDownloadTimeoutMinutes ), MaxResponseContentBufferSize = MaximumInstallerBytes };
|
|
var bytes = await client.GetByteArrayAsync( new Uri( url ), context.CancellationToken );
|
|
if ( bytes.Length > MaximumInstallerBytes )
|
|
{
|
|
throw new ToolException( $"Installer exceeds the {MaximumInstallerMebibytes} MiB limit." );
|
|
}
|
|
await File.WriteAllBytesAsync( installer, bytes, context.CancellationToken );
|
|
return await CheckArchInstaller( context, ["--installer", installer, "--archive", archive] );
|
|
}
|
|
|
|
private static async Task<int> VerifyArtifacts( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var root = Path.GetFullPath( parsed.TakeOption( "--artifact-root" ) ?? context.Environment( EnvironmentVariables.ArtifactRoot ) ?? "dist", context.RepositoryRoot );
|
|
var version = parsed.TakeOption( CommandLineOptions.Version ) ?? context.Environment( EnvironmentVariables.Version ) ?? VersionCommands.ReadCargoVersion( context.Path( RepositoryPaths.CargoManifest ) );
|
|
parsed.RequireEmpty( "package verify-artifacts [--artifact-root PATH] [--version VERSION]" );
|
|
var expected = version + "-1";
|
|
foreach ( var package in new[] { "wayscriber-amd64.deb", "wayscriber-configurator-amd64.deb" } )
|
|
{
|
|
var result = await context.Run( Programs.DpkgDeb, ["-f", Path.Combine( root, package ), "Version"], capture: true );
|
|
if ( result.StandardOutput.Trim( ) != expected )
|
|
{
|
|
throw new ToolException( $"{package} version mismatch." );
|
|
}
|
|
}
|
|
var mainDepends = await context.Run( Programs.DpkgDeb, ["-f", Path.Combine( root, "wayscriber-amd64.deb" ), "Depends"], capture: true );
|
|
RequireContains( mainDepends.StandardOutput,
|
|
[$"libc6 (>= {PackagingPlatform.MaximumGlibcVersion})", $"libgtk-4-1 (>= {PackagingPlatform.MinimumGtkVersion})"],
|
|
"wayscriber deb dependencies" );
|
|
if ( mainDepends.StandardOutput.Contains( "libgtk4-layer-shell" ) )
|
|
{
|
|
throw new ToolException( "Main deb retains dynamic gtk4-layer-shell dependency." );
|
|
}
|
|
var mainDebFiles = await context.Run( Programs.DpkgDeb, ["-c", Path.Combine( root, "wayscriber-amd64.deb" )], capture: true );
|
|
RequireContains( mainDebFiles.StandardOutput, ["/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell"], "wayscriber deb files" );
|
|
var configDepends = await context.Run( Programs.DpkgDeb, ["-f", Path.Combine( root, "wayscriber-configurator-amd64.deb" ), "Depends"], capture: true );
|
|
RequireContains( configDepends.StandardOutput,
|
|
[$"libc6 (>= {PackagingPlatform.MaximumGlibcVersion})", $"libadwaita-1-0 (>= {VersionCommands.SupportedLibadwaitaFloor})"],
|
|
"configurator deb dependencies" );
|
|
|
|
using var rpmDatabase = new TemporaryDirectory( "wayscriber-rpmdb" );
|
|
foreach ( var package in new[] { "wayscriber-x86_64.rpm", "wayscriber-configurator-x86_64.rpm" } )
|
|
{
|
|
var result = await context.Run( Programs.Rpm, ["--dbpath", rpmDatabase.Path, "-qp", "--qf", "%{VERSION}-%{RELEASE}\\n", Path.Combine( root, package )], capture: true );
|
|
if ( result.StandardOutput.Trim( ) != expected )
|
|
{
|
|
throw new ToolException( $"{package} version mismatch." );
|
|
}
|
|
}
|
|
var mainRpmRequires = await context.Run( Programs.Rpm, ["--dbpath", rpmDatabase.Path, "-qp", "--requires", Path.Combine( root, "wayscriber-x86_64.rpm" )], capture: true );
|
|
RequireLines( mainRpmRequires.StandardOutput,
|
|
[$"glibc >= {PackagingPlatform.MaximumGlibcVersion}", $"gtk4 >= {PackagingPlatform.MinimumGtkVersion}"],
|
|
"wayscriber rpm dependencies" );
|
|
if ( mainRpmRequires.StandardOutput.Contains( RepositoryNames.Gtk4LayerShell, StringComparison.Ordinal ) )
|
|
{
|
|
throw new ToolException( "Main rpm retains dynamic gtk4-layer-shell dependency." );
|
|
}
|
|
var mainRpmFiles = await context.Run( Programs.Rpm, ["--dbpath", rpmDatabase.Path, "-qlp", Path.Combine( root, "wayscriber-x86_64.rpm" )], capture: true );
|
|
RequireLines( mainRpmFiles.StandardOutput, ["/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell"], "wayscriber rpm files" );
|
|
var configRpmRequires = await context.Run( Programs.Rpm, ["--dbpath", rpmDatabase.Path, "-qp", "--requires", Path.Combine( root, "wayscriber-configurator-x86_64.rpm" )], capture: true );
|
|
RequireLines( configRpmRequires.StandardOutput,
|
|
[$"glibc >= {PackagingPlatform.MaximumGlibcVersion}", $"libadwaita >= {VersionCommands.SupportedLibadwaitaFloor}"],
|
|
"configurator rpm dependencies" );
|
|
|
|
var mainTar = await context.Run( Programs.Tar, ["-tzf", Path.Combine( root, $"wayscriber-v{version}-linux-x86_64.tar.gz" )], capture: true );
|
|
RequireSuffixes( mainTar.StandardOutput, ["/usr/bin/wayscriber", "/usr/share/licenses/wayscriber/LICENSE.gtk4-layer-shell"], "wayscriber tar files" );
|
|
var configTar = await context.Run( Programs.Tar, ["-tzf", Path.Combine( root, $"wayscriber-configurator-v{version}-linux-x86_64.tar.gz" )], capture: true );
|
|
RequireSuffixes( configTar.StandardOutput, ["/usr/bin/wayscriber-configurator"], "configurator tar files" );
|
|
await context.Output.WriteLineAsync( $"Verified release artifacts for {version}." );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task<int> SmokeUbuntu( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var image = parsed.TakeOption( "--image" ) ?? PackagingPlatform.UbuntuImage;
|
|
var root = Path.GetFullPath( parsed.TakeOption( "--artifact-root" ) ?? "dist", context.RepositoryRoot );
|
|
parsed.RequireEmpty( "package smoke-ubuntu [--image IMAGE] [--artifact-root PATH]" );
|
|
var name = $"wayscriber-package-smoke-{Guid.NewGuid( ):N}";
|
|
try
|
|
{
|
|
await context.Run( Programs.Docker, ["create", "--name", name, "-v", $"{root}:/dist:ro", image, Programs.Sleep, "infinity"] );
|
|
await context.Run( Programs.Docker, ["start", name] );
|
|
await context.Run( Programs.Docker, ["exec", name, Programs.AptGet, "update"] );
|
|
await context.Run( Programs.Docker, ["exec", "-e", "DEBIAN_FRONTEND=noninteractive", name, Programs.AptGet, "install", "-y", "/dist/wayscriber-amd64.deb", "/dist/wayscriber-configurator-amd64.deb"] );
|
|
await context.Run( Programs.Docker, ["exec", name, RepositoryNames.MainPackage, CommandLineOptions.Version] );
|
|
await context.Run( Programs.Docker, ["exec", name, Programs.Test, "-x", "/usr/bin/wayscriber-configurator"] );
|
|
}
|
|
finally
|
|
{
|
|
await context.Run( Programs.Docker, ["rm", "-f", name], allowedExitCodes: new HashSet<int> { ExitCodes.Success, ExitCodes.Failure } );
|
|
}
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task<int> InstallNfpm( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var version = parsed.TakeOption( CommandLineOptions.Version ) ?? DefaultNfpmVersion;
|
|
parsed.RequireEmpty( "ci install-nfpm [--version VERSION]" );
|
|
using var temporary = new TemporaryDirectory( Programs.Nfpm );
|
|
var archive = Path.Combine( temporary.Path, "nfpm.tar.gz" );
|
|
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes( NfpmDownloadTimeoutMinutes ) };
|
|
await using ( var output = File.Create( archive ) )
|
|
await using ( var input = await client.GetStreamAsync( $"https://github.com/goreleaser/nfpm/releases/download/v{version}/nfpm_{version}_Linux_x86_64.tar.gz", context.CancellationToken ) )
|
|
{
|
|
await input.CopyToAsync( output, context.CancellationToken );
|
|
}
|
|
await context.Run( Programs.Tar, ["-xzf", archive, CommandLineOptions.ChangeDirectory, temporary.Path, Programs.Nfpm] );
|
|
await context.Run( Programs.Sudo, [Programs.Install, InstallArguments.ExecutableFile, Path.Combine( temporary.Path, Programs.Nfpm ), "/usr/local/bin/nfpm"] );
|
|
await context.Run( Programs.Nfpm, [CommandLineOptions.Version] );
|
|
return ExitCodes.Success;
|
|
}
|
|
|
|
private static async Task<int> BuildRepositories( ToolContext context, string[] args )
|
|
{
|
|
var parsed = new Arguments( args );
|
|
var artifacts = Path.GetFullPath( parsed.TakeOption( "--artifact-root" ) ?? context.Environment( EnvironmentVariables.ArtifactRoot ) ?? "dist", context.RepositoryRoot );
|
|
var output = Path.GetFullPath( parsed.TakeOption( "--output-root" ) ?? context.Environment( EnvironmentVariables.OutputRoot ) ?? "dist/repos", context.RepositoryRoot );
|
|
var suite = parsed.TakeOption( "--deb-suite" ) ?? context.Environment( EnvironmentVariables.DebSuite ) ?? "stable";
|
|
var component = parsed.TakeOption( "--deb-component" ) ?? context.Environment( EnvironmentVariables.DebComponent ) ?? "main";
|
|
var arch = parsed.TakeOption( "--deb-arch" ) ?? context.Environment( EnvironmentVariables.DebArchitecture ) ?? "amd64";
|
|
var rpmArch = parsed.TakeOption( "--rpm-arch" ) ?? context.Environment( EnvironmentVariables.RpmArchitecture ) ?? "x86_64";
|
|
parsed.RequireEmpty( "package build-repositories [--artifact-root PATH] [--output-root PATH] [--deb-suite NAME] [--deb-component NAME] [--deb-arch ARCH] [--rpm-arch ARCH]" );
|
|
var mainDebName = $"{RepositoryNames.MainPackage}-amd64.deb";
|
|
var configuratorDebName = $"{RepositoryNames.ConfiguratorPackage}-amd64.deb";
|
|
var mainRpmName = $"{RepositoryNames.MainPackage}-{rpmArch}.rpm";
|
|
var configuratorRpmName = $"{RepositoryNames.ConfiguratorPackage}-{rpmArch}.rpm";
|
|
var mainDeb = Path.Combine( artifacts, mainDebName );
|
|
var mainRpm = Path.Combine( artifacts, mainRpmName );
|
|
if ( !File.Exists( mainDeb ) )
|
|
{
|
|
throw new ToolException( $"Missing deb package at {mainDeb}" );
|
|
}
|
|
if ( !File.Exists( mainRpm ) )
|
|
{
|
|
throw new ToolException( $"Missing rpm package at {mainRpm}" );
|
|
}
|
|
|
|
var outputParent = Path.GetDirectoryName( output );
|
|
if ( outputParent is null )
|
|
{
|
|
throw new ToolException( $"Cannot determine output parent for {output}." );
|
|
}
|
|
|
|
Directory.CreateDirectory( outputParent );
|
|
var staging = Path.Combine( outputParent, $".{Path.GetFileName( output )}.staging-{Guid.NewGuid( ):N}" );
|
|
Directory.CreateDirectory( staging );
|
|
try
|
|
{
|
|
using var signing = await RepositorySigning.Create( context, staging );
|
|
await BuildAptRepository( context, signing, staging, artifacts, [mainDebName, configuratorDebName], suite, component, arch );
|
|
await BuildRpmRepository( context, signing, staging, artifacts, [mainRpmName, configuratorRpmName] );
|
|
ReplaceRepositoryOutput( staging, output, outputParent );
|
|
|
|
await context.Error.WriteLineAsync( $"[build-repos] Repository build complete under {output}" );
|
|
return ExitCodes.Success;
|
|
}
|
|
finally
|
|
{
|
|
if ( Directory.Exists( staging ) )
|
|
{
|
|
Directory.Delete( staging, recursive: true );
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task BuildAptRepository( ToolContext context, RepositorySigning signing, string staging,
|
|
string artifacts, IEnumerable<string> packageNames, string suite, string component, string arch )
|
|
{
|
|
var aptRoot = Path.Combine( staging, "apt" );
|
|
var pool = Path.Combine( aptRoot, "pool/main/w/wayscriber" );
|
|
var binaryDirectory = Path.Combine( aptRoot, $"dists/{suite}/{component}/binary-{arch}" );
|
|
Directory.CreateDirectory( pool );
|
|
Directory.CreateDirectory( binaryDirectory );
|
|
CopyExistingPackages( artifacts, pool, packageNames );
|
|
|
|
var packages = await context.Run( Programs.AptFileArchive, ["packages", "pool"], aptRoot, capture: true );
|
|
var packageFile = Path.Combine( binaryDirectory, "Packages" );
|
|
Files.WriteAtomic( packageFile, packages.StandardOutput );
|
|
await using ( var input = File.OpenRead( packageFile ) )
|
|
await using ( var outputStream = File.Create( packageFile + ".gz" ) )
|
|
await using ( var gzip = new GZipStream( outputStream, CompressionLevel.SmallestSize ) )
|
|
{
|
|
await input.CopyToAsync( gzip, context.CancellationToken );
|
|
}
|
|
|
|
var release = await context.Run( Programs.AptFileArchive, ["-o", $"APT::FTPArchive::Release::Origin={context.Environment( EnvironmentVariables.RepositoryOrigin ) ?? "Wayscriber"}",
|
|
"-o", $"APT::FTPArchive::Release::Label={context.Environment( EnvironmentVariables.RepositoryLabel ) ?? "Wayscriber"}", "-o", $"APT::FTPArchive::Release::Suite={suite}",
|
|
"-o", $"APT::FTPArchive::Release::Codename={suite}", "-o", $"APT::FTPArchive::Release::Architectures={arch}",
|
|
"-o", $"APT::FTPArchive::Release::Components={component}", "release", $"dists/{suite}"], aptRoot, capture: true );
|
|
var releaseFile = Path.Combine( aptRoot, $"dists/{suite}/Release" );
|
|
Files.WriteAtomic( releaseFile, release.StandardOutput );
|
|
signing.CopyPublicKeys( aptRoot );
|
|
await signing.SignFile( context, releaseFile, Path.Combine( aptRoot, $"dists/{suite}/InRelease" ), clearSign: true );
|
|
await signing.SignFile( context, releaseFile, Path.Combine( aptRoot, $"dists/{suite}/Release.gpg" ), armor: true );
|
|
}
|
|
|
|
private static async Task BuildRpmRepository( ToolContext context, RepositorySigning signing, string staging,
|
|
string artifacts, IEnumerable<string> packageNames )
|
|
{
|
|
var rpmRoot = Path.Combine( staging, PackageFormats.Rpm );
|
|
Directory.CreateDirectory( rpmRoot );
|
|
CopyExistingPackages( artifacts, rpmRoot, packageNames );
|
|
signing.CopyRpmPublicKeys( rpmRoot );
|
|
if ( context.Environment( EnvironmentVariables.SignRpms ) != EnvironmentVariables.Disabled )
|
|
{
|
|
foreach ( var rpm in Directory.EnumerateFiles( rpmRoot, "*.rpm" ) )
|
|
{
|
|
await signing.SignRpm( context, rpm );
|
|
}
|
|
}
|
|
|
|
await context.Run( Programs.CreateRpmRepository, ["--update", rpmRoot] );
|
|
await signing.SignFile( context, Path.Combine( rpmRoot, "repodata/repomd.xml" ), Path.Combine( rpmRoot, "repodata/repomd.xml.asc" ), armor: true );
|
|
}
|
|
|
|
private static void CopyExistingPackages( string sourceDirectory, string destinationDirectory, IEnumerable<string> packageNames )
|
|
{
|
|
foreach ( var packageName in packageNames )
|
|
{
|
|
var source = Path.Combine( sourceDirectory, packageName );
|
|
if ( File.Exists( source ) )
|
|
{
|
|
File.Copy( source, Path.Combine( destinationDirectory, packageName ), true );
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ReplaceRepositoryOutput( string staging, string output, string outputParent )
|
|
{
|
|
string? backup = null;
|
|
if ( Directory.Exists( output ) )
|
|
{
|
|
backup = Path.Combine( outputParent, $".{Path.GetFileName( output )}.previous-{Guid.NewGuid( ):N}" );
|
|
Directory.Move( output, backup );
|
|
}
|
|
|
|
try
|
|
{
|
|
Directory.Move( staging, output );
|
|
}
|
|
catch
|
|
{
|
|
if ( backup is not null && !Directory.Exists( output ) )
|
|
{
|
|
Directory.Move( backup, output );
|
|
}
|
|
throw;
|
|
}
|
|
|
|
if ( backup is not null )
|
|
{
|
|
Directory.Delete( backup, recursive: true );
|
|
}
|
|
}
|
|
|
|
private static void RequireContains( string text, IEnumerable<string> values, string label )
|
|
{
|
|
foreach ( var value in values )
|
|
{
|
|
if ( !text.Contains( value, StringComparison.Ordinal ) )
|
|
{
|
|
throw new ToolException( $"{label} lacks {value}" );
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void RequireLines( string text, IEnumerable<string> values, string label )
|
|
{
|
|
var lines = text.Split( '\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ).ToHashSet( StringComparer.Ordinal );
|
|
foreach ( var value in values )
|
|
{
|
|
if ( !lines.Contains( value ) )
|
|
{
|
|
throw new ToolException( $"{label} lacks {value}" );
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void RequireSuffixes( string text, IEnumerable<string> values, string label )
|
|
{
|
|
var lines = text.Split( '\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries );
|
|
foreach ( var value in values )
|
|
{
|
|
if ( !lines.Any( line => line.EndsWith( value, StringComparison.Ordinal ) ) )
|
|
{
|
|
throw new ToolException( $"{label} lacks {value}" );
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class RepositorySigning : IDisposable
|
|
{
|
|
private const int ParametricRpmSigningMacroVersion = 6;
|
|
private const string RpmMacrosFileName = ".rpmmacros";
|
|
private const string RpmPassphraseFileName = "rpm-signing-passphrase";
|
|
|
|
private readonly TemporaryDirectory? _home;
|
|
private readonly string? _key;
|
|
private readonly string? _passphrase;
|
|
private readonly string? _publicArmor;
|
|
private readonly string? _publicBinary;
|
|
private readonly IReadOnlyDictionary<string, string?>? _environment;
|
|
|
|
private RepositorySigning( )
|
|
{
|
|
}
|
|
|
|
private RepositorySigning( TemporaryDirectory home, string key, string? passphrase, string armor, string binary )
|
|
{
|
|
_home = home;
|
|
_key = key;
|
|
_passphrase = passphrase;
|
|
_publicArmor = armor;
|
|
_publicBinary = binary;
|
|
_environment = new Dictionary<string, string?> { [EnvironmentVariables.GnuPgHome] = home.Path, [EnvironmentVariables.Home] = home.Path };
|
|
}
|
|
|
|
public static async Task<RepositorySigning> Create( ToolContext context, string output )
|
|
{
|
|
var encoded = context.Environment( EnvironmentVariables.GpgPrivateKeyBase64 );
|
|
if ( string.IsNullOrWhiteSpace( encoded ) )
|
|
{
|
|
await context.Error.WriteLineAsync( "[WARN] GPG_PRIVATE_KEY_B64 is not set; repositories will be unsigned." );
|
|
return new RepositorySigning( );
|
|
}
|
|
byte[] bytes;
|
|
try
|
|
{
|
|
bytes = Convert.FromBase64String( encoded );
|
|
}
|
|
catch ( FormatException error )
|
|
{
|
|
throw new ToolException( $"GPG_PRIVATE_KEY_B64 is invalid: {error.Message}" );
|
|
}
|
|
var home = new TemporaryDirectory( "wayscriber-gpg" );
|
|
if ( !OperatingSystem.IsWindows( ) )
|
|
{
|
|
File.SetUnixFileMode( home.Path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute );
|
|
}
|
|
var environment = new Dictionary<string, string?> { [EnvironmentVariables.GnuPgHome] = home.Path, [EnvironmentVariables.Home] = home.Path };
|
|
try
|
|
{
|
|
var privateKey = Path.Combine( home.Path, "repository-signing-key" );
|
|
Files.WriteAtomic( privateKey, bytes, UnixFileMode.UserRead | UnixFileMode.UserWrite );
|
|
await context.Run( Programs.Gpg, ["--batch", "--import", privateKey], environment: environment, capture: true );
|
|
var listed = await context.Run( Programs.Gpg, ["--batch", "--list-secret-keys", "--with-colons"], environment: environment, capture: true );
|
|
var detected = listed.StandardOutput.Split( '\n' ).Select( line => line.Split( ':' ) )
|
|
.FirstOrDefault( fields => fields.Length > GpgSecretKeyIdFieldIndex && fields[0] == "sec" )?[GpgSecretKeyIdFieldIndex];
|
|
var key = context.Environment( EnvironmentVariables.GpgKeyId ) ?? detected;
|
|
if ( string.IsNullOrWhiteSpace( key ) )
|
|
{
|
|
throw new ToolException( "Failed to detect imported GPG key id." );
|
|
}
|
|
var armor = Path.Combine( output, PublicKeyFileName );
|
|
var binary = Path.Combine( output, "WAYSCRIBER-GPG-KEY.gpg" );
|
|
await context.Run( Programs.Gpg, ["--batch", "--yes", "--armor", "--output", armor, "--export", key], environment: environment );
|
|
await context.Run( Programs.Gpg, ["--batch", "--yes", "--output", binary, "--export", key], environment: environment );
|
|
if ( context.Environment( EnvironmentVariables.SignRpms ) != EnvironmentVariables.Disabled )
|
|
{
|
|
var which = await context.Run( Programs.Which, [Programs.Gpg], environment: environment, capture: true );
|
|
var rpmVersion = await context.Run( Programs.Rpm, [CommandLineOptions.Version], environment: environment, capture: true );
|
|
var rpmMajorVersion = ParseRpmMajorVersion( rpmVersion.StandardOutput );
|
|
var passphraseFile = Path.Combine( home.Path, RpmPassphraseFileName );
|
|
Files.WriteAtomic( passphraseFile, (context.Environment( EnvironmentVariables.GpgPassphrase ) ?? string.Empty) + "\n",
|
|
UnixFileMode.UserRead | UnixFileMode.UserWrite );
|
|
var macros = CreateRpmMacros( rpmMajorVersion, key, home.Path, which.StandardOutput.Trim( ), passphraseFile );
|
|
Files.WriteAtomic( Path.Combine( home.Path, RpmMacrosFileName ), macros );
|
|
}
|
|
|
|
await context.Error.WriteLineAsync( $"[build-repos] Using GPG key: {key}" );
|
|
return new RepositorySigning( home, key, context.Environment( EnvironmentVariables.GpgPassphrase ), armor, binary );
|
|
}
|
|
catch { home.Dispose( ); throw; }
|
|
}
|
|
|
|
internal static int ParseRpmMajorVersion( string output )
|
|
{
|
|
var match = Regex.Match( output, @"\b(?<major>[0-9]+)(?:\.[0-9]+)+\b" );
|
|
if ( !match.Success || !int.TryParse( match.Groups["major"].Value, out var majorVersion ) )
|
|
{
|
|
throw new ToolException( $"Cannot determine RPM version from: {output.Trim( )}" );
|
|
}
|
|
|
|
return majorVersion;
|
|
}
|
|
|
|
internal static string CreateRpmMacros( int rpmMajorVersion, string key, string home, string gpgPath, string passphraseFile )
|
|
{
|
|
var common = $"%_signature gpg\n%_gpg_name {key}\n%_gpg_path {home}\n%__gpg {gpgPath}\n";
|
|
if ( rpmMajorVersion >= ParametricRpmSigningMacroVersion )
|
|
{
|
|
return common + $"%_openpgp_sign_id {key}\n%_gpg_sign_cmd_extra_args --batch --pinentry-mode loopback --passphrase-file {passphraseFile}\n";
|
|
}
|
|
|
|
return common + $"%__gpg_sign_cmd %{{__gpg}} --batch --pinentry-mode loopback --passphrase-file {passphraseFile} --no-armor " +
|
|
"--detach-sign --sign --local-user \"%{_gpg_name}\" --output %{__signature_filename} %{__plaintext_filename}\n";
|
|
}
|
|
|
|
public void CopyPublicKeys( string destination )
|
|
{
|
|
if ( _publicArmor is null || _publicBinary is null )
|
|
{
|
|
return;
|
|
}
|
|
File.Copy( _publicArmor, Path.Combine( destination, Path.GetFileName( _publicArmor ) ), true );
|
|
File.Copy( _publicBinary, Path.Combine( destination, Path.GetFileName( _publicBinary ) ), true );
|
|
}
|
|
|
|
public void CopyRpmPublicKeys( string destination )
|
|
{
|
|
if ( _publicArmor is null )
|
|
{
|
|
return;
|
|
}
|
|
File.Copy( _publicArmor, Path.Combine( destination, "RPM-GPG-KEY-wayscriber.asc" ), true );
|
|
File.Copy( _publicArmor, Path.Combine( destination, "RPM-GPG-KEY-wayscriber" ), true );
|
|
}
|
|
|
|
public async Task SignRpm( ToolContext context, string rpm )
|
|
{
|
|
if ( _key is null )
|
|
{
|
|
return;
|
|
}
|
|
await context.Run( Programs.RpmSign, ["--addsign", rpm], environment: _environment );
|
|
}
|
|
|
|
public async Task SignFile( ToolContext context, string source, string destination, bool armor = false, bool clearSign = false )
|
|
{
|
|
if ( _key is null )
|
|
{
|
|
return;
|
|
}
|
|
var arguments = new List<string> { "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-fd", "0", "--local-user", _key };
|
|
if ( clearSign )
|
|
{
|
|
arguments.Add( "--clearsign" );
|
|
}
|
|
else
|
|
{
|
|
arguments.Add( "--detach-sign" );
|
|
if ( armor )
|
|
{
|
|
arguments.Add( "--armor" );
|
|
}
|
|
}
|
|
arguments.AddRange( ["--output", destination, source] );
|
|
await context.Run( Programs.Gpg, arguments, environment: _environment, input: (_passphrase ?? string.Empty) + "\n" );
|
|
}
|
|
|
|
public void Dispose( ) => _home?.Dispose( );
|
|
}
|
|
}
|