This repository packages reusable GitHub Actions workflows and Bash automation that can be plugged into any .NET solution. All shell entry points sit under scripts/bash/ and are linted during CI with ShellCheck to keep the scripts portable and robust.
These top-level workflows are intended to be called directly via workflow_call from dependent repositories. They orchestrate the full CI/CD pipeline, and fan out to lower-level building blocks - reusable workflows and bash scripts as needed. The workflows and scripts share a common input surface to make it easy to toggle behavior across the pipeline:
- Common switches for all bash scripts:
help: Iftrue, scripts will display usage information and exit (default:false)debugger: Set when the a script is running under a debugger, e.g. 'gdb'. If specified, the script will not set traps for DEBUG and EXIT, and will set the '--quiet' switch. (default:false)dry-run: Iftrue, scripts will simulate actions without making changes (default:false)quiet: Iftrue, scripts will suppress all functions that request input from the user - confirmations - Y/N, choices - 1) 2)..., etc. and will assume some sensible default input. (default:false, in CI -true)verbose: Iftrue, scripts will emit tracing and messages from alltrace()calls, all executed commands, and all variable dumps (default:false)
- Switches and options for the CI workflows and bash scripts:
target-os: Operating systems to run the jobs on (default:ubuntu-latest)dotnet-version: .NET SDK version to install (default:10.0.x)configuration: Build configuration (default:Release)preprocessor-symbols: Optional preprocessor symbols to pass todotnet build(e.g. SHORT_RUN for benchmarks) (default: empty)test-project: Relative path to the test project to execute (default:tests/UnitTests/UnitTests.csproj)min-coverage-pct: Minimum acceptable line coverage percentage (default:80)run-benchmarks: Whether to run benchmarks as part of the CI (default:true)benchmark-project: Relative path to the benchmark project to execute (default:benchmarks/Benchmarks/Benchmarks.csproj)force-new-baseline: Ignore the current baseline and make the current benchmark results the new baseline (default:false)max-regression-pct: Maximum acceptable regression percentage (default:10)
- Orchestrates the full pipeline:
- Build
- Test
- Run benchmark tests
- Normalizes all incoming inputs (target OS, .NET SDK, configuration, defined symbols, etc.) through
validate-vars.sh(see the list of parameters above). - Fans out to the lower-level reusable workflows (
build.yaml,test.yaml,benchmarks.yaml). - Uploads/Downloads artifacts from/to artifact directories (
TestArtifacts,BmArtifacts) so downstream jobs and scripts stay in sync, compare with previous versions (esp. for benchmarks), track progress of non-functional changes (e.g. test coverage and performance benchmarks), etc. history.
- Triggers on pushes to
mainor manual dispatches tomain. - Computes semantic prerelease tags (
vX.Y.(Z+1)-<prefix>.<YYYYMMDD>.<run>) using MinVer conventions, - Pushes the tag
- Packs with
dotnet pack, and publishes to NuGet as a prerelease - Runs the CI workflow again with all default parameters (see above) to ensure the code is in a good state before packing
- Accepts switches for
- prerelease tag prefix (e.g.
preview,beta,alpha) - customizing the prerelease label
- optionally uploading the produced
.nupkgfiles as workflow artifacts for later inspection. - forcing publication
- prerelease tag prefix (e.g.
- Ships stable releases off of
v*tags -^v[0-9]+(\.[0-9]+)(\.[0-9]+)(\.[0-9]+)$(e.g.v2.1.3) or manual dispatches. - Runs a
Releasepack - Pushes packages to NuGet
- Shares the same input surface as the prerelease workflow, making it easy to toggle behavior between prerelease and stable channels.
These workflows are included by the high-level orchestrators, but can also be consumed individually if you only need part of the pipeline. E.g. all scripts are designed to be reusable and callable either from a workflow or directly from the command line. E.g. you can call run-tests.sh from your own workflow if you want to run tests with coverage but don't need the full CI.
- Checks out the repository
- Installs the requested .NET SDK
- Runs
dotnet buildwith optional preprocessor symbols - Makes all scripts under
scripts/bash/executable (chmod +x) - Runs ShellCheck (
ludeeus/action-shellcheck) acrossscripts/bash/ - Populates
$GITHUB_STEP_SUMMARYwith build results
- Provisions the .NET SDK
- Calls
scripts/bash/run-tests.shto execute a specified test project with coverage collection - Publishes the resulting
TestArtifactsdirectory (coverage reports, logs) as an artifact for future inspections - Populates
$GITHUB_STEP_SUMMARYwith coverage results, and fails the job if coverage is below the configured threshold
- Restores baseline benchmark summaries (if available) via
download-artifact.sh - Executes
scripts/bash/run-benchmarks.sh - Analyses the results and compares to the baseline (results from previous runs)
- Enforces regression thresholds
- Always publishes the latest benchmark summaries
- Optionally pushes a refreshed baseline when large improvements are observed
- When large regressions are detected, the job fails and the summary contains guidance on how to proceed, possibly by forcing a new baseline
All scripts live under scripts/bash/ and follow a three-file convention:
- the main script
*.usage.shfile that defines help text*.utils.shhelper that encapsulates argument parsing
They all source _common.sh for shared behavior and respect common flags (--verbose, --quiet, --trace, --dry-run, --debugger, see above).
- Run
./scripts/restore-locked.sh [solution-or-project]to refresh lockfiles with--force-evaluate, then verify them with--locked-mode(mirrors CI enforcement). Defaults tovm2.Glob.slnxwhen no target is provided.
- Validates and normalizes workflow inputs, emitting derived values for downstream jobs in
$GITHUB_OUTPUT - Ensures consistent environment variable defaults for the pipeline
- Runs
dotnet testwith configurable build configuration, preprocessor symbols, and coverage thresholds - Manages artifacts (
TestArtifacts/Results, coverage summaries) - Installs/uninstalls the
dotnet-reportgenerator-globaltoolon demand - Populates
$GITHUB_STEP_SUMMARYwith coverage outcomes and e - Exits with a non-zero status when coverage falls below the configured threshold
- Executes BenchmarkDotNet projects via
dotnet run - Exports JSON results and compact summaries (rendered using
jqand the querysummary.jq) - Compares current performance against stored baselines
- Sets
FORCE_NEW_BASELINEwhen improvements/regressions exceed significantly the configured tolerances. This causes thebenchmarks.yamlto upload the results as new baselines.
- Downloads artifacts from prior workflow runs using the GitHub REST APIs and
ghCLI semantics. - Used by
benchmarks.yamlto hydrate baseline data before running new benchmarks, but is general-purpose for any artifact retrieval task.
- Shared utility library that wires in tracing, verbosity, CI-safe defaults, and interactive prompts.
- Implements helpers for argument parsing (
get_common_arg()), logging (trace(),dump_vars()), command execution with dry-run support (execute()), user prompts (choose(),confirm(),press_any_key()), and numeric/string validation helpers (is_integer,is_in, etc.). - Should be sourced by all new scripts to ensure consistent behavior across the automation surface.
debugger: Iftrue, indicates the script is running under a debugger, e.g. 'gdb'. If specified, the script will not set traps for DEBUG and EXIT (see below), and will set the '--quiet' as user input from stdin interferes with the debugger. (default:false)verbose: Iftrue, enables the output from the functionsexecute(),trace(), anddump_vars()(default:false)dry_run: Iftrue, simulates actions without making changes (default:false)quiet: Iftrue, suppresses all functions that request input from the user - confirmations - Y/N, choices - 1) 2)..., etc. and will assume some sensible default input. (default:false, in CI -true)ci: Iftrue, indicates the script is running in a CI environment, as always set by GitHub Actions (default:false, ortruein GitHub Actions)_ignore: The file to redirect unwanted output to (default:/dev/null). When the calling script sets the common flag--trace, this is set to/dev/stdout`` so that the output from all executed commands are visible.common_switches: A string that contains documentation of all common switches passed to the calling script's functionget_common_arg(). For reuse by the calling scripts in their help strings.
-
on_debug()andon_exit(): bash DEBUG and EXIT trap handlers that remember the last invoked bash command in$last_command. Used byon_exit()to report the last command when the script exits with an error. -
set-*functions are invoked when the script is initializing from external environment variables or common arguments are being applied to the calling script (seeget_common_arg()).set_ci(): when the variableCIistrue, sets the following variables as follows:citotruequiettotruedebuggertofalseverbosetofalsedry_runtofalse_ignoreto/dev/nullset +x- disables bash tracing
set_debugger(): sets (except whenciistrue):debuggertotruequiettotrue
set_trace_enabled(): whentrue, sets (except whenciistrue):verbosetotrue_ignoreto/dev/stdout``set -xenables bash tracing
set_dry_run(): whentrue, sets (except whenciistrue)dry_runtotrueset_quiet(): whentrue, sets (except whenciistrue)quiettotrueset_verbose(): whentrue, setsverbosetotrue. Note that verbose is not disabled in CI, as it is useful when debugging workflows or to see trace output fromexecute(),trace(), anddump_vars()calls.
-
dump_vars(): dumps the values of all passed variables tostdout. Useful for debugging. Pass the name of the variables you want dumped (without the$), e.g.dump_vars var1 var2. Also you can pass "flags" between the variable names:-for--force: dump the variables even ifverboseis nottrue. Useful when you want to see variable dumps in quiet mode or in CI.-hor--headerfollowed by a header string: include a header line before or between the variable dumps-bor--blank: include a blank line between variable dumps-lor--line: include a line between variable dumps
-
is_defined(): returns0if the passed variable is defined (not null),1otherwise. Usage:is_defined var_name(without the$). -
write_line(): for internal use bydump_vars() -
get_common_arg(): parses common arguments passed to the calling script and invokes the correspondingset-*functions. Usage:get_common_arg "$@"(pass all script arguments). Recognizes the following arguments:--debugger: callsset_debugger()(see above)--quiet,-q: callsset_quiet()(see above)--verbose,-v: callsset_verbose()(see above)--trace,-x: callsset_trace_enabled()(see above)--dry-run,-n: callsset_dry_run()(see above)
Returns
0if a common argument was found and processed,1otherwise. -
display_usage_msg(): suppresses temporarily the bash tracing (if enabled) and displays the passed usage message. Usage:display_usage_msg "$usage_msg"(pass the usage message as a single string). -
trace(): ifverboseistrue, prints the passed message tostdout. Usage:trace "message". -
execute(): depending on the value ofdry_run, either executes or just displays what would have been executed. Usage:execute "command". E.g.:execute sudo apt-get update && sudo apt-get install -y gh jqexecute mkdir -p "$artifacts_dir"
Suggestion: use the execute function to run commands that have no side effects, i.e. do not change the system state, e.g. install/uninstall software, create/delete files or directories, etc.
-
to_lower()andto_upper(): converts the passed string to lower or upper case and outputs the result tostdout. Usage:to_lower "STRING". E.g.lower_str=$(to_lower "$str") -
is_*predicates are useful for arguments validation:is_integer(): returns0if its parameter represents a valid integer number,1otherwiseis_non_positive(): returns0if its parameter represents a valid non-positive, integer number: {..., -3, -2, -1, 0},1otherwiseis_positive(): returns0if its parameter represents a valid positive, integer number (aka natural number): {1, 2, 3, ...},1otherwiseis_non_negative(): returns0if its parameter represents a valid non-negative, integer number: {0, 1, 2, 3, ...},1otherwiseis_negative(): returns0if its parameter represents a valid negative: {..., -3, -2, -1},1otherwiseis_integer(): returns0if its parameter represents a valid integer number (..., -2, -1, 0, 1, 2, ...),1otherwiseis_decimal(): returns0if its parameter represents a valid decimal number,1otherwiseis_in(): returns0if the first parameter is found in the list of subsequent parameters,1otherwise. Usage:is_in "value" "list_item1" "list_item2" ...
-
list_of_files(): given a file pattern, lists all files as a bash list that match the pattern tostdout. Usage:list_of_files "pattern". E.g.files=$(list_of_files "*.json") -
User interaction functions:
-
press_any_key(): prompts the user to press any key to continue. Usage:press_any_key "Prompt message". Ifquietistrue, does nothing and returns immediately. -
confirm(): prompts the user with a Y/N question and returns0if the answer is yes,1otherwise. Usage:if confirm "Are you sure?"; then ...; fi. Ifquietistrue, assumes the default answer is yes. -
choose(): prompts the user to choose one of the passed options and returns the selected option tostdout. Usage:choose "Prompt message" "Option 1" "Option 2" .... The function automatically displays the options in a numbered list and outputs the user's choice tostdout. E.g.:choice=$(choose \ "The benchmark results directory '$artifacts_dir' already exists. What do you want to do?" \ "Clobber the directory '$artifacts_dir' with the new contents" \ "Move the contents of the directory to '$renamed_artifacts_dir', and continue" \ "Delete the contents of the directory, and continue" \ "Exit the script") || exit $?
If
quietistrue, assumes the first option is selected. -
get_credentials(): prompts the user to enter a username and password, and returns them via predefined variablesusernameandpassword. Usage:get_credentials "Prompt message".credentials=$(get_credentials "Enter your user ID: " "Enter your password: " "Are these correct?") || exit $? username=${credentials%%:*} password=${credentials#*:}
If
quietistrue, returns ":".
-
-
scp_retry(): attempts to SSH copy a file viascpup to a specified number of times with a delay between attempts. Usage:scp_retry "source" "destination" max_attempts delay_seconds. E.g.scp_retry "file.txt" "user@host:/path/" 5 10tries to copyfile.txttouser@host:/path/up to 5 times, waiting 10 seconds between attempts. -
Test functions for building test harnesses:
fail(): prints the passed message tostderrand exits with status1. Usage:fail "Error message". E.g.if ! is_integer "$var"; then fail "The variable 'var' must be an integer"; fiassert_eq(): compares two values and exits with status1if they are not equal. Usage:assert_eq "value1" "value2" "Error message". E.g.assert_eq "$expected" "$actual" "The actual value does not match the expected value"assert_true(): checks if the passed expression is true and exits with status1if it is not. Usage:assert_true "expression" "Error message". E.g.assert_true "$var" "The variable 'var' must be true"; fiassert_false(): checks if the passed expression is false and exits with status1if it is not. Usage:assert_false "expression" "Error message". E.g.assert_false "$var" "The variable 'var' must be false"; fi
- All workflows assume .NET 10.0.x SDKs; update the workflow inputs if you need to target a different version.
- Scripts rely on
bashand standard GNU utilities available on Ubuntu GitHub-hosted runners. Any additional tooling they need (e.g.,jq,reportgenerator) is installed on demand. - When adding new scripts, follow the existing three-file pattern and keep code ShellCheck-clean so the shared lint step continues to pass.
- For lockfile hygiene before committing, run
./scripts/restore-locked.sh [solution-or-project]; it refreshes locks with--force-evaluateand then verifies them with--locked-mode(mirrors CI). Defaults tovm2.Glob.slnxif no target is provided.