From 3076a957bc7130e6272c15441ee06630cb1705c9 Mon Sep 17 00:00:00 2001 From: Louis Seubert Date: Thu, 12 Feb 2026 21:18:29 +0100 Subject: [PATCH] wip --- .editorconfig | 419 ++++++++++++++++++++++++ .forgejo/workflows/example.yaml | 16 + .gitignore | 2 + Directory.Build.props | 37 +++ Directory.Build.targets | 2 + Directory.Packages.props | 16 + core.slnx | 4 + global.json | 11 + module/artifact-pull/README.md | 13 + module/artifact-pull/action.yml | 38 +++ module/artifact-push/README.md | 16 + module/artifact-push/action.yml | 54 +++ module/checkout/README.md | 11 + module/checkout/action.yml | 35 ++ module/release/README.md | 17 + module/release/action.yml | 69 ++++ nuget.config | 19 ++ src/core.next/Commands/Checkout.cs | 105 ++++++ src/core.next/Program.cs | 29 ++ src/core.next/ShellLikeString.cs | 118 +++++++ src/core.next/_extensions/collection.cs | 13 + src/core.next/_extensions/string.cs | 17 + src/core.next/core.next.csproj | 23 ++ src/core/Commands/Artifact.cs | 136 ++++++++ src/core/Commands/Checkout.cs | 53 +++ src/core/Commands/Release.cs | 57 ++++ src/core/GitHubEnvironmentContext.cs | 14 + src/core/IGitHubEnvironmentContext.cs | 22 ++ src/core/Parsers.cs | 101 ++++++ src/core/Program.cs | 26 ++ src/core/Properties/Resources.resx | 35 ++ src/core/Shellify.cs | 90 +++++ src/core/core.csproj | 35 ++ 33 files changed, 1653 insertions(+) create mode 100644 .editorconfig create mode 100644 .forgejo/workflows/example.yaml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 Directory.Packages.props create mode 100644 core.slnx create mode 100644 global.json create mode 100644 module/artifact-pull/README.md create mode 100644 module/artifact-pull/action.yml create mode 100644 module/artifact-push/README.md create mode 100644 module/artifact-push/action.yml create mode 100644 module/checkout/README.md create mode 100644 module/checkout/action.yml create mode 100644 module/release/README.md create mode 100644 module/release/action.yml create mode 100644 nuget.config create mode 100644 src/core.next/Commands/Checkout.cs create mode 100644 src/core.next/Program.cs create mode 100644 src/core.next/ShellLikeString.cs create mode 100644 src/core.next/_extensions/collection.cs create mode 100644 src/core.next/_extensions/string.cs create mode 100644 src/core.next/core.next.csproj create mode 100644 src/core/Commands/Artifact.cs create mode 100644 src/core/Commands/Checkout.cs create mode 100644 src/core/Commands/Release.cs create mode 100644 src/core/GitHubEnvironmentContext.cs create mode 100644 src/core/IGitHubEnvironmentContext.cs create mode 100644 src/core/Parsers.cs create mode 100644 src/core/Program.cs create mode 100644 src/core/Properties/Resources.resx create mode 100644 src/core/Shellify.cs create mode 100644 src/core/core.csproj diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3848020 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,419 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +tab_width = 4 +end_of_line = lf +insert_final_newline = false +trim_trailing_whitespace = true +max_line_length = 120 + +[*.{md,json,yaml,yml}] +indent_size = 2 +indent_style = space +trim_trailing_whitespace = false + +[*.{csproj,props,targets,slnx}] +indent_size = 2 +indent_style = space + +[nuget.config] +indent_size = 2 +indent_style = space + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +file_header_template = Copyright (c) The Geekeey Authors\nSPDX-License-Identifier: EUPL-1.2 +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +dotnet_diagnostic.IDE0005.severity = suggestion # https://github.com/dotnet/roslyn/issues/41640 + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences + +dotnet_diagnostic.IDE0270.severity = none +dotnet_style_coalesce_expression = true # IDE0029,IDE0030,IDE0270 + +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_compound_assignment = true + +dotnet_diagnostic.IDE0045.severity = suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true # IDE0045 +dotnet_diagnostic.IDE0046.severity = suggestion +dotnet_style_prefer_conditional_expression_over_return = true # IDE0046 + +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true +dotnet_style_namespace_match_folder = false # resharper: resharper_check_namespace_highlighting + +# Field preferences +dotnet_style_readonly_field = true + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# ReSharper preferences +resharper_wrap_object_and_collection_initializer_style = chop_always +resharper_check_namespace_highlighting = none +resharper_csharp_wrap_lines = false + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = false +csharp_style_expression_bodied_methods = false +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async + +# Code-block preferences +csharp_prefer_braces = true +csharp_prefer_simple_using_statement = true + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_inlined_variable_declaration = true +csharp_style_pattern_local_over_anonymous_function = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_range_operator = true +csharp_style_throw_expression = true + +dotnet_diagnostic.IDE0058.severity = suggestion +csharp_style_unused_value_assignment_preference = discard_variable # IDE0058 +csharp_style_unused_value_expression_statement_preference = discard_variable # IDE0058 + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace + +# 'namespace' preferences +csharp_style_namespace_declarations = file_scoped + +# 'constructor' preferences +csharp_style_prefer_primary_constructors = false + +#### C# Formatting Rules #### +[*.cs] + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### .NET Naming styles #### +[*.{cs,vb}] + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +# local + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +# private + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +# public + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +# others + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + +[*.{cs,vb}] +dotnet_analyzer_diagnostic.category-style.severity = warning +dotnet_analyzer_diagnostic.category-design.severity = warning +dotnet_analyzer_diagnostic.category-globalization.severity = notice +dotnet_analyzer_diagnostic.category-naming.severity = warning +dotnet_analyzer_diagnostic.category-performance.severity = warning +dotnet_analyzer_diagnostic.category-reliability.severity = warning +dotnet_analyzer_diagnostic.category-security.severity = warning +dotnet_analyzer_diagnostic.category-usage.severity = warning +dotnet_analyzer_diagnostic.category-maintainability.severity = warning + +dotnet_diagnostic.CA1716.severity = none # Identifiers should not match keywords +dotnet_diagnostic.CA1816.severity = suggestion # Dispose methods should call SuppressFinalize +dotnet_diagnostic.CA1848.severity = none # Use the LoggerMessage delegates +dotnet_diagnostic.IDE0210.severity = none # Use top-level statements diff --git a/.forgejo/workflows/example.yaml b/.forgejo/workflows/example.yaml new file mode 100644 index 0000000..d422fce --- /dev/null +++ b/.forgejo/workflows/example.yaml @@ -0,0 +1,16 @@ +name: example +on: + push: + branches: [ main ] + workflow_dispatch: + +jobs: + do-stuff: + runs-on: debian-latest + steps: + - name: Checkout repository + uses: https://code.geekeey.de/actions/core-test/module/checkout@master + - name: Do stuff + run: | + echo "Doing stuff..." + ls -al \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3509a45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +artifacts/ +*.DotSettings.user \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..7996aa7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,37 @@ + + + $(MSBuildThisFileDirectory)artifacts + + + + enable + enable + + + + 1.0.0 + + + + true + nullable + true + + + + The Geekeey Team + Copyright (c) The Geekeey Team 2026 + true + true + snupkg + + + + + + + + moderate + all + + \ No newline at end of file diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..c1df222 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..409f197 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,16 @@ + + + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/core.slnx b/core.slnx new file mode 100644 index 0000000..652e95e --- /dev/null +++ b/core.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..76286dc --- /dev/null +++ b/global.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://www.schemastore.org/global.json", + "sdk": { + "version": "10.0.0", + "rollForward": "latestMinor" + }, + "msbuild-sdks": {}, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/module/artifact-pull/README.md b/module/artifact-pull/README.md new file mode 100644 index 0000000..4779d59 --- /dev/null +++ b/module/artifact-pull/README.md @@ -0,0 +1,13 @@ +# artifact-pull + +## Usage + +```yml +uses: actions/core/module/artifact-pull@1.0.0 +with: + name: artifact + pattern: | + build/**/* + !build/exclude-* + run-id: 0001 +``` diff --git a/module/artifact-pull/action.yml b/module/artifact-pull/action.yml new file mode 100644 index 0000000..66f6678 --- /dev/null +++ b/module/artifact-pull/action.yml @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: EUPL-1.2 +name: "Release" +description: "Pull build artifacts from the pipeline artifact storage" +author: "Louis Seubert" +inputs: + name: + required: true + description: > + The name of the artifact to download. The name is used to identify the artifact in the + artifact storage and must be unique within the repository. + pattern: + required: false + description: > + Paths of files which will be extracted from the artifact. Can be multiple lines with file system globbing + patterns. A line starting with ! will negate the pattern and act as exclude pattern. If this input is not provided, + all files of the artifact will be extracted. + run-id: + required: false + description: > + The id of the workflow run from which the artifact should be downloaded. If this input is not provided, the + artifact from the current workflow run will be downloaded. + default: "${{ github.run_id }}" +runs: + using: 'docker' + image: 'code.geekeey.de/actions/core:1.0.0' + args: + - '--server' + - '${{ github.server_url }}' + - '--token' + - '${{ github.token }}' + - 'artifact' + - 'pull' + - '--name' + - '${{ inputs.name }}' + - '--pattern' + - '${{ inputs.pattern }}' + - '--run-id' + - '${{ inputs.run-id }}' diff --git a/module/artifact-push/README.md b/module/artifact-push/README.md new file mode 100644 index 0000000..f105bd2 --- /dev/null +++ b/module/artifact-push/README.md @@ -0,0 +1,16 @@ +# artifact-push + +## Usage + +```yml +uses: actions/core/module/artifact-push@1.0.0 +with: + name: artifact + path: | + build/**/* + !build/exclude-* + compression: 0 + retention-days: 30 + overwrite: false + include-hidden-files: false +``` diff --git a/module/artifact-push/action.yml b/module/artifact-push/action.yml new file mode 100644 index 0000000..5179e88 --- /dev/null +++ b/module/artifact-push/action.yml @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: EUPL-1.2 +name: "Release" +description: "Push build artifacts to the pipeline artifact storage" +author: "Louis Seubert" +inputs: + name: + required: true + description: > + The name of the artifact to upload. The name is used to identify the artifact in the + artifact storage and must be unique within the repository. + pattern: + required: true + description: > + Paths to files which will be uploaded as artifact. Can be multiple lines with file system + globbing patterns. A line starting with ! will negate the pattern and act as exclude + pattern. + retention-days: + required: false + description: > + The number of days to keep the artifact in the artifact storage. After this period, the artifact will be + automatically deleted. The default is 0 days which is the defaul value of the runner. + default: 0 + overwrite: + required: false + description: > + Whether an artifact with the same name should be overwritten if it already exists in the artifact storage. + The default is false. + default: "false" + include-hidden: + required: false + description: > + Whether hidden files should be included in the artifact. A hidden file is a file which + starts with a dot (.) and is not the current or parent directory. Default is false. + default: "false" +runs: + using: 'docker' + image: 'code.geekeey.de/actions/core:1.0.0' + args: + - '--server' + - '${{ github.server_url }}' + - '--token' + - '${{ github.token }}' + - 'artifact' + - 'push' + - '--name' + - '${{ inputs.name }}' + - '--pattern' + - '${{ inputs.pattern }}' + - '--retention-days' + - '${{ inputs.retention-days }}' + - '--overwrite' + - '${{ inputs.overwrite }}' + - '--include-hidden' + - '${{ inputs.include-hidden }}' diff --git a/module/checkout/README.md b/module/checkout/README.md new file mode 100644 index 0000000..f4de137 --- /dev/null +++ b/module/checkout/README.md @@ -0,0 +1,11 @@ +# checkout + +## Usage + +```yml +uses: actions/core/module/checkout@1.0.0 +with: + repository: ${{ github.server_url }}/${{ github.repository }}.git + path: ${{github.workspace}} + ref: ${{ github.ref || github.sha }} +``` diff --git a/module/checkout/action.yml b/module/checkout/action.yml new file mode 100644 index 0000000..0e27f7c --- /dev/null +++ b/module/checkout/action.yml @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: EUPL-1.2 +name: "Checkout" +description: "Checkout a Git repository at a particular version" +author: "Louis Seubert" +inputs: + repository: + required: false + description: > + The path to the repository to checkout. Must be accessible with the + pipeline token or publicly. Can be either an HTTPS or SSH URL. + default: "${{ github.server_url }}/${{ github.repository }}.git" + path: + required: false + description: > + The directory to checkout the repository into. If the directory does not + exist, it will be created. + default: "${{ github.workspace }}" + ref: + required: false + description: > + The branch, tag or SHA to checkout. When checking out the repository that + triggered a workflow, this defaults to the reference or SHA for that + event. Otherwise, uses the default branch. + default: "${{ github.ref || github.sha }}" +runs: + using: 'docker' + image: 'docker://code.geekeey.de/actions/core:1.0.0' + args: + - 'checkout' + - '--repository' + - '${{ inputs.repository }}' + - '--path' + - '${{ inputs.path }}' + - '--ref' + - '${{ inputs.ref }}' \ No newline at end of file diff --git a/module/release/README.md b/module/release/README.md new file mode 100644 index 0000000..2c4ca31 --- /dev/null +++ b/module/release/README.md @@ -0,0 +1,17 @@ +# release + +## Usage + +```yml +uses: actions/core/module/release@1.0.0 +with: + repository: ${{ github.server_url }}/${{ github.repository }} + version: ${{ github.ref_name }} + draft: false # or pattern to match agains version e.g. '\d+\.\d+\.\d+' + prerelease: false # or pattern to match agains version e.g. '.*-rc\.\d+' + title: Release ${{ github.ref_name }} + notes: ${{ steps.changelog.outputs.changelog }} + attachments: | + ${{ github.workspace }}/build/*.zip + !${{ github.workspace }}/build/exclude-*.zip +``` diff --git a/module/release/action.yml b/module/release/action.yml new file mode 100644 index 0000000..10feb25 --- /dev/null +++ b/module/release/action.yml @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: EUPL-1.2 +name: "Release" +description: "Create a release from a Git tag and attache file artifacts" +author: "Louis Seubert" +inputs: + repository: + required: false + description: > + The owner and repository name seperated by a slash of the repository in which + the release should be created. The repository must be on the same instance as + the action is running on. + default: "${{ github.server_url }}/${{ github.repository }}" + version: + required: false + description: > + The tag name of the git tag for which the release should be created and to which + the artifacts should be attached to. + default: "${{ github.ref_name }}" + draft: + required: false + description: > + A boolean value or a regex pattern which will be evaluated against the version + to determine if the created release will be saved as draft. + default: "false" + prerelease: + required: false + description: > + A boolean value or a regex pattern which will be evaluated against the version + to determine if the created release will be marked as pre-release. + default: "false" + title: + required: false + description: > + The title of the created release. Leave this empty to use the label of the + referenced git tag. + notes: + required: false + description: > + The message of the created release. Leave this empty to use the notes of the + referenced git tag. + attachments: + required: false + description: > + Paths to files which will be attached to the release. Can be multiple lines + with file system globbing patterns. A line starting with ! will negate the + pattern and act as exclude pattern. +runs: + using: 'docker' + image: 'code.geekeey.de/actions/core:1.0.0' + args: + - '--server' + - '${{ github.server_url }}' + - '--token' + - '${{ github.token }}' + - 'release' + - '--repository' + - '${{ inputs.repository }}' + - '--version' + - '${{ inputs.version }}' + - '--draft' + - '${{ inputs.draft }}' + - '--prerelease' + - '${{ inputs.prerelease }}' + - '--title' + - '${{ inputs.title }}' + - '--notes' + - '${{ inputs.notes }}' + - '--attachments' + - '${{ inputs.attachments }}' diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..131917b --- /dev/null +++ b/nuget.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core.next/Commands/Checkout.cs b/src/core.next/Commands/Checkout.cs new file mode 100644 index 0000000..b41c715 --- /dev/null +++ b/src/core.next/Commands/Checkout.cs @@ -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 Repository = new("--repository") { Required = true }; + private static readonly Option Destination = new("--path") { Required = true }; + private static readonly Option 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 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; + } +} \ No newline at end of file diff --git a/src/core.next/Program.cs b/src/core.next/Program.cs new file mode 100644 index 0000000..4555272 --- /dev/null +++ b/src/core.next/Program.cs @@ -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 Server = new("--server") { Required = true, Recursive = true }; + public static readonly Option 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 Main(string[] args) + { + return new Program().Parse(args).InvokeAsync(); + } +} \ No newline at end of file diff --git a/src/core.next/ShellLikeString.cs b/src/core.next/ShellLikeString.cs new file mode 100644 index 0000000..037b12f --- /dev/null +++ b/src/core.next/ShellLikeString.cs @@ -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 GetAwaiter(this string command) + { + return RunProcessAsync(command).GetAwaiter(); + } + + private static async Task 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 ShellSplit(string input) + { + var args = new List(); + + 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); +} \ No newline at end of file diff --git a/src/core.next/_extensions/collection.cs b/src/core.next/_extensions/collection.cs new file mode 100644 index 0000000..ba72067 --- /dev/null +++ b/src/core.next/_extensions/collection.cs @@ -0,0 +1,13 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +internal static partial class Extensions +{ + public static void Add(this ICollection collection, IEnumerable items) + { + foreach (var item in items) + { + collection.Add(item); + } + } +} \ No newline at end of file diff --git a/src/core.next/_extensions/string.cs b/src/core.next/_extensions/string.cs new file mode 100644 index 0000000..d48e80d --- /dev/null +++ b/src/core.next/_extensions/string.cs @@ -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; + } +} \ No newline at end of file diff --git a/src/core.next/core.next.csproj b/src/core.next/core.next.csproj new file mode 100644 index 0000000..7698d97 --- /dev/null +++ b/src/core.next/core.next.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + true + true + Geekeey.Actions.Core + + + + code.geekeey.de + actions/core + 1.0.0 + + + + + + + \ No newline at end of file diff --git a/src/core/Commands/Artifact.cs b/src/core/Commands/Artifact.cs new file mode 100644 index 0000000..84678ea --- /dev/null +++ b/src/core/Commands/Artifact.cs @@ -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 Name = new("--name") + { + Required = true + }; + + private static readonly Option Pattern = new("--pattern") + { + CustomParser = Parsers.Matcher + }; + + private static readonly Option RunId = new("--run-id") + { + Required = false + }; + + public Pull() : base("pull") + { + Add(Name); + Add(Pattern); + Add(RunId); + SetAction(HandleAsync); + } + + private Task HandleAsync(ParseResult result, CancellationToken cancellationToken) + { + // Implementation for checkout command goes here. + return Task.FromResult(0); + } + } + + private sealed class Push : Command + { + private static readonly Option Name = new("--name") + { + Required = true + }; + + private static readonly Option Pattern = new("--pattern") + { + CustomParser = Parsers.Matcher + }; + + private static readonly Option IncludeHidden = new("--include-hidden") + { + Required = false + }; + + private static readonly Option RetentionDays = new("--retention-days") + { + Required = false + }; + + private static readonly Option Overwrite = new("--overwrite") + { + Required = false + }; + + public Push() : base("push") + { + Add(Name); + Add(Pattern); + Add(IncludeHidden); + Add(RetentionDays); + Add(Overwrite); + SetAction(HandleAsync); + } + + private Task HandleAsync(ParseResult result, CancellationToken cancellationToken) + { + // Implementation for checkout command goes here. + return Task.FromResult(0); + } + } +} \ No newline at end of file diff --git a/src/core/Commands/Checkout.cs b/src/core/Commands/Checkout.cs new file mode 100644 index 0000000..d6d1232 --- /dev/null +++ b/src/core/Commands/Checkout.cs @@ -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 Repository = new("--repository") { Required = true, CustomParser = Parsers.Uri }; + private static readonly Option Destination = new("--path") { Required = true }; + private static readonly Option Reference = new("--reference") { Required = true }; + private static readonly Option 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 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; + } +} \ No newline at end of file diff --git a/src/core/Commands/Release.cs b/src/core/Commands/Release.cs new file mode 100644 index 0000000..6f455d6 --- /dev/null +++ b/src/core/Commands/Release.cs @@ -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 Repository = new("--repository") { Required = true, CustomParser = Parsers.Uri }; + private static readonly Option Version = new("--version") { Required = true }; + + private static readonly Option Draft = new("--draft") { CustomParser = Parsers.Pattern }; + private static readonly Option PreRelease = new("--prerelease") { CustomParser = Parsers.Pattern }; + + private static readonly Option Title = new("--title"); + private static readonly Option Notes = new("--notes"); + private static readonly Option 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 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); + } +} diff --git a/src/core/GitHubEnvironmentContext.cs b/src/core/GitHubEnvironmentContext.cs new file mode 100644 index 0000000..f27534f --- /dev/null +++ b/src/core/GitHubEnvironmentContext.cs @@ -0,0 +1,14 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Core; + +/// +/// Provides access to GitHub Actions environment context from environment variables. +/// +internal sealed class GitHubEnvironmentContext : IGitHubEnvironmentContext +{ + public string? BaseRef => Environment.GetEnvironmentVariable("GITHUB_BASE_REF"); + + public string? HeadRef => Environment.GetEnvironmentVariable("GITHUB_HEAD_REF"); +} \ No newline at end of file diff --git a/src/core/IGitHubEnvironmentContext.cs b/src/core/IGitHubEnvironmentContext.cs new file mode 100644 index 0000000..e6596a4 --- /dev/null +++ b/src/core/IGitHubEnvironmentContext.cs @@ -0,0 +1,22 @@ +// Copyright (c) The Geekeey Authors +// SPDX-License-Identifier: EUPL-1.2 + +namespace Geekeey.Core; + +/// +/// Provides access to GitHub Actions environment context. +/// +public interface IGitHubEnvironmentContext +{ + /// + /// Gets the base reference for pull requests. + /// Corresponds to the GITHUB_BASE_REF environment variable. + /// + string? BaseRef { get; } + + /// + /// Gets the head reference for pull requests. + /// Corresponds to the GITHUB_HEAD_REF environment variable. + /// + string? HeadRef { get; } +} \ No newline at end of file diff --git a/src/core/Parsers.cs b/src/core/Parsers.cs new file mode 100644 index 0000000..b349e37 --- /dev/null +++ b/src/core/Parsers.cs @@ -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; + } +} diff --git a/src/core/Program.cs b/src/core/Program.cs new file mode 100644 index 0000000..3e764da --- /dev/null +++ b/src/core/Program.cs @@ -0,0 +1,26 @@ +using System.CommandLine; +using Geekeey.Core.Commands; + +namespace Geekeey.Core; + +internal sealed class Program : RootCommand +{ + public static readonly Option Server = new("--server") { Required = true, Recursive = true }; + public static readonly Option 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 Main(string[] args) + { + var program = new Program(); + return program.Parse(args).InvokeAsync(); + } +} diff --git a/src/core/Properties/Resources.resx b/src/core/Properties/Resources.resx new file mode 100644 index 0000000..087c59a --- /dev/null +++ b/src/core/Properties/Resources.resx @@ -0,0 +1,35 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, + PublicKeyToken=b77a5c561934e089 + + + + X + + + Checkout a specific version or branch of a git repository. + + + Release a version to the release page on the repository. + + diff --git a/src/core/Shellify.cs b/src/core/Shellify.cs new file mode 100644 index 0000000..c929668 --- /dev/null +++ b/src/core/Shellify.cs @@ -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 + } + + /// + /// Splits a shell-escaped string into arguments, handling quotes and escapes. + /// + public static List ShellSplit(string input) + { + var args = new List(); + 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 GetAwaiter(this string command) + { + return RunProcessAsync(command).GetAwaiter(); + } + + private static async Task 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; + } +} \ No newline at end of file diff --git a/src/core/core.csproj b/src/core/core.csproj new file mode 100644 index 0000000..4c6d474 --- /dev/null +++ b/src/core/core.csproj @@ -0,0 +1,35 @@ + + + + Exe + net10.0 + enable + enable + + Geekeey.Core + + true + true + true + + true + alpine + linux-x64;linux-arm64 + + + + Recommended + true + + + + + + + + + + + + + \ No newline at end of file