This commit is contained in:
Louis Seubert 2026-02-12 21:18:29 +01:00
commit 03ebf47b9f
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
33 changed files with 1657 additions and 0 deletions

View file

@ -0,0 +1,105 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.CommandLine;
using System.Text;
namespace Geekeey.Actions.Core.Commands;
internal sealed class Checkout : Command
{
#pragma warning disable format // @formatter:off
private static readonly Option<Uri> Repository = new("--repository") { Required = true };
private static readonly Option<DirectoryInfo> Destination = new("--path") { Required = true };
private static readonly Option<string> Reference = new("--reference") { Required = true };
#pragma warning restore format // @formatter:on
internal Checkout() : base("checkout")
{
Add(Repository);
Add(Destination);
Add(Reference);
SetAction(HandleAsync);
}
private async Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
{
var server = result.GetRequiredValue(Program.Server);
var access = result.GetRequiredValue(Program.Token);
var workspace = result.GetRequiredValue(Destination);
await $"git init -q {workspace.FullName}";
await $"git -C {workspace.FullName} config set --local protocol.version 2";
await $"git -C {workspace.FullName} config set --local gc.auto 0";
var repository = result.GetRequiredValue(Repository);
if (!repository.IsAbsoluteUri)
{
// relative repository urls are resolved against the server url
repository = new Uri(server, repository);
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf git@{repository.Host}";
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf ssh://git@{repository.Host}";
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf git://{repository.Host}";
var header = $"Authorization: Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes($"x-access-token::${access}"))}";
await $"git -C {workspace.FullName} config set --local http.{repository}.extraheader '{header}'";
}
var origin = "origin";
var reference = result.GetRequiredValue(Reference);
await $"git -C {workspace.FullName} remote add {origin} {repository}";
var list = (await $"git -C {workspace.FullName} ls-remote {origin} {reference}").Split('\t');
if (list.Length < 2)
{
throw new InvalidOperationException("git ls-remote resolved nothing");
}
var @sha = list[0].Trim();
var @ref = list[1].Trim();
if (@ref.TryCutPrefix("refs/heads/", out var name))
{
var remote = $"refs/remotes/{origin}/{name}";
var branch = name;
await $"git -C {workspace.FullName} fetch --no-tags --prune --no-recurse-submodules --depth=1 {origin} +{@sha}:{remote}";
await $"git -C {workspace.FullName} checkout --force -B {branch} {remote}";
}
else if (@ref.TryCutPrefix("refs/pull/", out name))
{
var remote = $"refs/remotes/pull/{name}";
// Best-effort parity with the Go code's env.BaseRef/env.HeadRef:
var baseRef = Environment.GetEnvironmentVariable("GITHUB_BASE_REF");
var headRef = Environment.GetEnvironmentVariable("GITHUB_HEAD_REF");
var branch =
!string.IsNullOrEmpty(baseRef) ? baseRef :
!string.IsNullOrEmpty(headRef) ? headRef :
throw new InvalidOperationException("pull request can not find base ref for branch");
await $"git -C {workspace.FullName} fetch --no-tags --prune --no-recurse-submodules --depth=1 {origin} +{@sha}:{remote}";
await $"git -C {workspace.FullName} checkout --force -B {branch} {branch}";
}
else if (@ref.TryCutPrefix("refs/tags/", out name))
{
var remote = $"refs/tags/{name}";
await $"git -C {workspace.FullName} fetch --no-tags --prune --no-recurse-submodules --depth=1 {origin} +{@sha}:{remote}";
await $"git -C {workspace.FullName} checkout --force {remote}";
}
else
{
await $"git -C {workspace.FullName} fetch --no-tags --prune --no-recurse-submodules --depth=1 {origin} {@ref}";
await $"git -C {workspace.FullName} checkout --force {@ref}";
}
return 0;
}
}

29
src/core.next/Program.cs Normal file
View file

@ -0,0 +1,29 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.CommandLine;
using Geekeey.Actions.Core.Commands;
namespace Geekeey.Actions.Core;
internal sealed class Program : RootCommand
{
#pragma warning disable format // @formatter:off
public static readonly Option<Uri> Server = new("--server") { Required = true, Recursive = true };
public static readonly Option<string> Token = new("--token") { Required = true, Recursive = true };
#pragma warning restore format // @formatter:on
private Program()
{
Add(Server);
Add(Token);
Add(new Checkout());
}
private static Task<int> Main(string[] args)
{
return new Program().Parse(args).InvokeAsync();
}
}

View file

@ -0,0 +1,118 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
namespace Geekeey.Actions.Core;
internal static class ShellLikeString
{
public static TaskAwaiter<string> GetAwaiter(this string command)
{
return RunProcessAsync(command).GetAwaiter();
}
private static async Task<string> RunProcessAsync(string command)
{
var strings = ShellSplit(command);
var psi = new ProcessStartInfo
{
FileName = strings.First(),
ArgumentList =
{
strings.Skip(1)
},
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi)!;
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
var outputs = await Task.WhenAll(stdout, stderr);
if (process.ExitCode is not 0)
{
throw new ProcessExitException($"Process exited with code {process.ExitCode}: {stderr}");
}
return outputs[0];
}
private static List<string> ShellSplit(string input)
{
var args = new List<string>();
if (string.IsNullOrEmpty(input))
{
return args;
}
var current = new StringBuilder();
var state = ShellSplitState.None;
foreach (var c in input)
{
if (state.HasFlag(ShellSplitState.Escape))
{
current.Append(c);
state &= ~ShellSplitState.Escape;
}
else
{
switch (c)
{
case '\\':
state |= ShellSplitState.Escape;
break;
case '\'' when !state.HasFlag(ShellSplitState.InDoubleQuote):
state ^= ShellSplitState.InSingleQuote;
break;
case '\"' when !state.HasFlag(ShellSplitState.InSingleQuote):
state ^= ShellSplitState.InDoubleQuote;
break;
default:
if (char.IsWhiteSpace(c) && !state.HasFlag(ShellSplitState.InSingleQuote) && !state.HasFlag(ShellSplitState.InDoubleQuote))
{
if (current.Length > 0)
{
args.Add(current.ToString());
current.Clear();
}
}
else
{
current.Append(c);
}
break;
}
}
}
if (current.Length > 0)
{
args.Add(current.ToString());
}
return args;
}
[Flags]
private enum ShellSplitState
{
None,
InSingleQuote,
InDoubleQuote,
Escape,
}
private sealed class ProcessExitException(string message) : Exception(message);
}

View file

@ -0,0 +1,13 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
internal static partial class Extensions
{
public static void Add<T>(this ICollection<T> collection, IEnumerable<T> items)
{
foreach (var item in items)
{
collection.Add(item);
}
}
}

View file

@ -0,0 +1,17 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
internal static partial class Extensions
{
public static bool TryCutPrefix(this string value, string prefix, out string rest)
{
if (value.StartsWith(prefix, StringComparison.Ordinal))
{
rest = value[prefix.Length..];
return true;
}
rest = string.Empty;
return false;
}
}

View file

@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<RootNamespace>Geekeey.Actions.Core</RootNamespace>
</PropertyGroup>
<PropertyGroup>
<ContainerRegistry>code.geekeey.de</ContainerRegistry>
<ContainerRepository>actions/core</ContainerRepository>
<ContainerImageTag>1.0.0</ContainerImageTag>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,136 @@
using System.CommandLine;
using System.IO.Compression;
using Geekeey.Core.Properties;
using Microsoft.Extensions.FileSystemGlobbing;
namespace Geekeey.Core.Commands;
internal sealed class Artifact : Command
{
public Artifact() : base("artifact", Resources.ArtifactCommandDescription)
{
Add(new Pull());
Add(new Push());
}
private static async Task CreateArtifactFromDirectoryAsync(Stream stream, DirectoryInfo directory, Matcher matcher, CancellationToken cancellationToken)
{
await using var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
var files = matcher.GetResultsInFullPath(directory.FullName);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
var path = Path.GetRelativePath(directory.FullName, file);
var name = path.Replace(Path.DirectorySeparatorChar, '/');
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
entry.LastWriteTime = File.GetLastWriteTime(file);
await using var zos = await entry.OpenAsync(cancellationToken);
await using var fis = File.OpenRead(file);
await fis.CopyToAsync(zos, cancellationToken);
}
}
private static async Task ExtractArtifactToDirectoryAsync(Stream stream, DirectoryInfo directory, Matcher matcher, CancellationToken cancellationToken)
{
await using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
var entries = archive.Entries.Where(entry => matcher.Match(entry.FullName).HasMatches);
foreach (var entry in entries)
{
cancellationToken.ThrowIfCancellationRequested();
var path = Path.Combine(directory.FullName, entry.FullName);
var file = new FileInfo(path);
file.Directory?.Create();
await using var zis = await entry.OpenAsync(cancellationToken);
await using var fos = File.Create(file.FullName);
await zis.CopyToAsync(fos, cancellationToken);
}
}
private sealed class Pull : Command
{
private static readonly Option<string> Name = new("--name")
{
Required = true
};
private static readonly Option<Matcher?> Pattern = new("--pattern")
{
CustomParser = Parsers.Matcher
};
private static readonly Option<int?> RunId = new("--run-id")
{
Required = false
};
public Pull() : base("pull")
{
Add(Name);
Add(Pattern);
Add(RunId);
SetAction(HandleAsync);
}
private Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
{
// Implementation for checkout command goes here.
return Task.FromResult(0);
}
}
private sealed class Push : Command
{
private static readonly Option<string> Name = new("--name")
{
Required = true
};
private static readonly Option<Matcher?> Pattern = new("--pattern")
{
CustomParser = Parsers.Matcher
};
private static readonly Option<bool> IncludeHidden = new("--include-hidden")
{
Required = false
};
private static readonly Option<int> RetentionDays = new("--retention-days")
{
Required = false
};
private static readonly Option<bool> Overwrite = new("--overwrite")
{
Required = false
};
public Push() : base("push")
{
Add(Name);
Add(Pattern);
Add(IncludeHidden);
Add(RetentionDays);
Add(Overwrite);
SetAction(HandleAsync);
}
private Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
{
// Implementation for checkout command goes here.
return Task.FromResult(0);
}
}
}

View file

@ -0,0 +1,53 @@
using System.CommandLine;
using System.Text;
using Geekeey.Core.Properties;
namespace Geekeey.Core.Commands;
internal sealed class Checkout : Command
{
private static readonly Option<Uri> Repository = new("--repository") { Required = true, CustomParser = Parsers.Uri };
private static readonly Option<DirectoryInfo> Destination = new("--path") { Required = true };
private static readonly Option<string> Reference = new("--reference") { Required = true };
private static readonly Option<bool> TreeLessClone = new("--tree-less-clone") { Description = "Use tree-less (blob-less) clone to reduce bandwidth" };
public Checkout() : base("checkout", Resources.CheckoutCommandDescription)
{
Add(Repository);
Add(Destination);
Add(Reference);
Add(TreeLessClone);
SetAction(HandleAsync);
}
private async Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
{
var server = result.GetRequiredValue(Program.Server);
var access = result.GetRequiredValue(Program.Token);
// relative repository urls are resolved against the server url
var repository = result.GetRequiredValue(Repository);
if (!repository.IsAbsoluteUri)
{
repository = new Uri(new Uri(server), repository);
}
var workspace = result.GetRequiredValue(Destination);
await $"git init -q {workspace.FullName}";
await $"git -C {workspace.FullName} config set --local protocol.version 2";
await $"git -C {workspace.FullName} config set --local gc.auto 0";
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf git@{repository.Host}";
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf ssh://git@{repository.Host}";
await $"git -C {workspace.FullName} config set --local --append url.{repository}.insteadOf git://{repository.Host}";
var header = $"Authorization: Basic {Convert.ToBase64String(Encoding.UTF8.GetBytes(access))}";
await $"git -C {workspace.FullName} config set --local http.{repository}.extraheader '{header}'";
await $"git -C {workspace.FullName} remote add origin {repository}";
await $"git -C {workspace.FullName} ls-remote origin {result.GetRequiredValue(Reference)}";
return 0;
}
}

View file

@ -0,0 +1,57 @@
using System.CommandLine;
using Geekeey.Core.Properties;
using Microsoft.Extensions.FileSystemGlobbing;
namespace Geekeey.Core.Commands;
internal sealed class Release : Command
{
private static readonly Option<Uri> Repository = new("--repository") { Required = true, CustomParser = Parsers.Uri };
private static readonly Option<string> Version = new("--version") { Required = true };
private static readonly Option<Pattern> Draft = new("--draft") { CustomParser = Parsers.Pattern };
private static readonly Option<Pattern> PreRelease = new("--prerelease") { CustomParser = Parsers.Pattern };
private static readonly Option<string> Title = new("--title");
private static readonly Option<string> Notes = new("--notes");
private static readonly Option<Matcher> Attachments = new("--attachments") { CustomParser = Parsers.Matcher };
public Release() : base("release", Resources.ReleaseCommandDescription)
{
Add(Repository);
Add(Version);
Add(Draft);
Add(PreRelease);
Add(Title);
Add(Notes);
Add(Attachments);
SetAction(HandleAsync);
}
private Task<int> HandleAsync(ParseResult result, CancellationToken cancellationToken)
{
var server = result.GetRequiredValue(Program.Server);
var access = result.GetRequiredValue(Program.Token);
// relative repository urls are resolved against the server url
var repository = result.GetRequiredValue(Repository);
if (!repository.IsAbsoluteUri)
{
repository = new Uri(new Uri(server), repository);
}
var version = result.GetRequiredValue(Version);
var draft = result.GetValue(Draft);
var prerelease = result.GetValue(PreRelease);
var title = result.GetValue(Title);
var notes = result.GetValue(Notes);
var attachments = result.GetValue(Attachments);
// Implementation for checkout command goes here.
return Task.FromResult(0);
}
}

View file

@ -0,0 +1,14 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Core;
/// <summary>
/// Provides access to GitHub Actions environment context from environment variables.
/// </summary>
internal sealed class GitHubEnvironmentContext : IGitHubEnvironmentContext
{
public string? BaseRef => Environment.GetEnvironmentVariable("GITHUB_BASE_REF");
public string? HeadRef => Environment.GetEnvironmentVariable("GITHUB_HEAD_REF");
}

View file

@ -0,0 +1,22 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.Core;
/// <summary>
/// Provides access to GitHub Actions environment context.
/// </summary>
public interface IGitHubEnvironmentContext
{
/// <summary>
/// Gets the base reference for pull requests.
/// Corresponds to the GITHUB_BASE_REF environment variable.
/// </summary>
string? BaseRef { get; }
/// <summary>
/// Gets the head reference for pull requests.
/// Corresponds to the GITHUB_HEAD_REF environment variable.
/// </summary>
string? HeadRef { get; }
}

101
src/core/Parsers.cs Normal file
View file

@ -0,0 +1,101 @@
using System.CommandLine.Parsing;
using System.Text.RegularExpressions;
using Microsoft.Extensions.FileSystemGlobbing;
namespace Geekeey.Core;
internal static class Parsers
{
public static Uri? Uri(ArgumentResult result)
{
if (!result.Tokens.Any())
{
return null;
}
if (!System.Uri.TryCreate(result.Tokens.Single().Value, UriKind.RelativeOrAbsolute, out var uri))
{
result.AddError("Not a valid URI.");
return null;
}
return uri;
}
public static Pattern? Pattern(ArgumentResult result)
{
if (!result.Tokens.Any())
{
return null;
}
if (bool.TryParse(result.Tokens.Single().Value, out var value))
{
return new Pattern(value);
}
try
{
var regex = new Regex(result.Tokens.Single().Value, RegexOptions.Compiled | RegexOptions.IgnoreCase);
return new Pattern(regex);
}
catch (ArgumentException)
{
result.AddError("Not a valid boolean or regex pattern.");
return null;
}
}
public static Matcher? Matcher(ArgumentResult result)
{
if (!result.Tokens.Any())
{
return null;
}
var matcher = new Matcher();
foreach (var token in result.Tokens)
{
try
{
if (token.Value.StartsWith('!'))
{
matcher.AddExclude(token.Value[1..]);
}
else
{
matcher.AddInclude(token.Value);
}
}
catch (Exception exception)
{
result.AddError($"Not a valid glob pattern: {token.Value}. {exception.Message}");
return null;
}
}
return matcher;
}
}
internal sealed class Pattern
{
private readonly bool _value;
private readonly Regex? _pattern;
public Pattern(bool value)
{
_value = value;
}
public Pattern(Regex pattern)
{
_pattern = pattern;
}
public bool Match(string version)
{
return _pattern?.IsMatch(version) ?? _value;
}
}

26
src/core/Program.cs Normal file
View file

@ -0,0 +1,26 @@
using System.CommandLine;
using Geekeey.Core.Commands;
namespace Geekeey.Core;
internal sealed class Program : RootCommand
{
public static readonly Option<string> Server = new("--server") { Required = true, Recursive = true };
public static readonly Option<string> Token = new("--token") { Required = true, Recursive = true };
private Program()
{
Add(Server);
Add(Token);
Add(new Checkout());
Add(new Release());
Add(new Artifact());
}
public static Task<int> Main(string[] args)
{
var program = new Program();
return program.Parse(args).InvokeAsync();
}
}

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089
</value>
</resheader>
<data name="ArtifactCommandDescription" xml:space="preserve">
<value>X</value>
</data>
<data name="CheckoutCommandDescription" xml:space="preserve">
<value>Checkout a specific version or branch of a git repository.</value>
</data>
<data name="ReleaseCommandDescription" xml:space="preserve">
<value>Release a version to the release page on the repository.</value>
</data>
</root>

90
src/core/Shellify.cs Normal file
View file

@ -0,0 +1,90 @@
using System.Diagnostics;
using System.Runtime.CompilerServices;
internal static class Shellify
{
[Flags]
private enum ShellSplitState
{
None = 0,
InSingleQuote = 1 << 0,
InDoubleQuote = 1 << 1,
Escape = 1 << 2
}
/// <summary>
/// Splits a shell-escaped string into arguments, handling quotes and escapes.
/// </summary>
public static List<string> ShellSplit(string input)
{
var args = new List<string>();
if (string.IsNullOrEmpty(input)) return args;
var current = new System.Text.StringBuilder();
var state = ShellSplitState.None;
foreach (char c in input)
{
if (state.HasFlag(ShellSplitState.Escape))
{
current.Append(c);
state &= ~ShellSplitState.Escape;
}
else if (c == '\\')
{
state |= ShellSplitState.Escape;
}
else if (c == '\'' && !state.HasFlag(ShellSplitState.InDoubleQuote))
{
state ^= ShellSplitState.InSingleQuote;
}
else if (c == '"' && !state.HasFlag(ShellSplitState.InSingleQuote))
{
state ^= ShellSplitState.InDoubleQuote;
}
else if (char.IsWhiteSpace(c) && !state.HasFlag(ShellSplitState.InSingleQuote) && !state.HasFlag(ShellSplitState.InDoubleQuote))
{
if (current.Length > 0)
{
args.Add(current.ToString());
current.Clear();
}
}
else
{
current.Append(c);
}
}
if (current.Length > 0)
args.Add(current.ToString());
return args;
}
public static TaskAwaiter<string> GetAwaiter(this string command)
{
return RunProcessAsync(command).GetAwaiter();
}
private static async Task<string> RunProcessAsync(string command)
{
var strings = ShellSplit(command);
var psi = new ProcessStartInfo
{
FileName = strings.First(),
ArgumentList =
{
strings.Skip(1)
},
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
string output = await process.StandardOutput.ReadToEndAsync();
string error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new System.Exception($"Process exited with code {process.ExitCode}: {error}");
return output;
}
}

35
src/core/core.csproj Normal file
View file

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Geekeey.Core</RootNamespace>
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<InvariantGlobalization>true</InvariantGlobalization>
<EnableSdkContainerSupport>true</EnableSdkContainerSupport>
<ContainerFamily>alpine</ContainerFamily>
<ContainerRuntimeIdentifiers>linux-x64;linux-arm64</ContainerRuntimeIdentifiers>
</PropertyGroup>
<PropertyGroup>
<AnalysisMode>Recommended</AnalysisMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Commands\Checkout.messages.resx" ClassName="Checkout">
</EmbeddedResource>
</ItemGroup>
</Project>