From 0a9cb67055a99ce5418b0968ccbe949e4b940cec Mon Sep 17 00:00:00 2001 From: Shane Jaroch Date: Wed, 31 Dec 2025 23:35:42 -0500 Subject: [PATCH] feat: Add Makefile, CI, and improved testing infrastructure Adds a Makefile for standardizing test/lint/install workflows, a GitHub Actions CI workflow, and coverage reporting tools. Also updates install.sh to support version detection. Signed-off-by: Shane Jaroch --- .github/workflows/ci.yaml | 102 +++++++++++++++++++++ .gitignore | 8 ++ Makefile | 160 +++++++++++++++++++++++++++++++++ git-remote-gcrypt | 12 ++- install.sh | 75 ++++++++++++---- tests/coverage_report.py | 42 +++++++++ tests/system-test.sh | 27 ++++-- tests/test-install-logic.sh | 106 ++++++++++++++++++++++ tests/verify-system-install.sh | 59 ++++++++++++ 9 files changed, 566 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 Makefile create mode 100644 tests/coverage_report.py create mode 100755 tests/test-install-logic.sh create mode 100644 tests/verify-system-install.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..62e1af8 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,102 @@ +--- +name: CI + +"on": + push: + workflow_dispatch: + inputs: + debug: + description: 'Enable debug logging (GCRYPT_DEBUG=1)' + required: false + type: boolean + default: false + schedule: + - cron: "0 0 * * 0" # Sunday at 12 AM + +jobs: + # Handles Ubuntu and macOS + install-unix: + runs-on: ${{ matrix.os }} + + env: + GCRYPT_DEBUG: ${{ inputs.debug && '1' || '' }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Dependencies (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y git python3-docutils + + - name: Dependencies (macOS) + if: runner.os == 'macOS' + run: brew install coreutils python3 git docutils + + - name: Help [make] + run: make + + - name: Test Installer + run: bash ./tests/test-install-logic.sh + + - name: Install [make install] + run: sudo make install + + - name: Verify [make check/install] + run: make check/install + + + # Handles RedHat (UBI Container) + install-rh: + runs-on: ubuntu-latest + + env: + GCRYPT_DEBUG: ${{ inputs.debug && '1' || '' }} + + container: + image: registry.access.redhat.com/ubi9/ubi:latest + + steps: + - uses: actions/checkout@v4 + + # dnf is slow in containers. We cache the dnf cache directory. + - name: Cache DNF + uses: actions/cache@v4 + with: + path: /var/cache/dnf + key: ${{ runner.os }}-ubi9-dnf-v1 + restore-keys: | + ${{ runner.os }}-ubi9-dnf- + + - name: Dependencies [redhat] + run: dnf install -y git python3-docutils make man-db + + - name: Help [make] + run: make + + - name: Test Installer + run: bash ./tests/test-install-logic.sh + + - name: Install [make install] + run: make install # container runs as sudo + + - name: Verify [make check/install] + run: make check/install + + + # Lint job (no-op currently) + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install ShellCheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + + - name: Lint [make lint] + continue-on-error: true + run: make lint diff --git a/.gitignore b/.gitignore index 2395a05..d6d106f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ /debian/files /debian/git-remote-gcrypt.substvars /debian/git-remote-gcrypt + +# Test coverage +.coverage/ +!.coverage/** +# scratch pad +.tmp/ +!.tmp/** + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a1d9670 --- /dev/null +++ b/Makefile @@ -0,0 +1,160 @@ +SHELL:=/bin/bash +# .ONESHELL: +# .EXPORT_ALL_VARIABLES: +.DEFAULT_GOAL := _help +.SHELLFLAGS = -ec + +.PHONY: _help +_help: + @printf "\nUsage: make , valid commands:\n\n" + @awk 'BEGIN {FS = ":.*?##H "}; \ + /##H/ && !/@awk.*?##H/ { \ + target=$$1; doc=$$2; \ + if (length(target) > max) max = length(target); \ + targets[NR] = target; docs[NR] = doc; list[NR] = 1; \ + } \ + END { \ + for (i = 1; i <= NR; i++) { \ + if (list[i]) printf " \033[1;34m%-*s\033[0m %s\n", max, targets[i], docs[i]; \ + } \ + print ""; \ + }' $(MAKEFILE_LIST) + +.PHONY: _all +_all: lint test/installer test/system + @$(call print_success,All checks passed!) + +.PHONY: vars +vars: ##H Debug: Print project variables + @$(foreach v,$(sort $(.VARIABLES)), \ + $(if $(filter file command line override,$(origin $(v))), \ + $(info $(v) = $($(v))) \ + ) \ + ) + +define print_err + printf "\033[1;31m%s\033[0m\n" "$(1)" +endef + +define print_warn + printf "\033[1;33m%s\033[0m\n" "$(1)" +endef + +define print_success + printf "\033[1;34m✓ %s\033[0m\n" "$(1)" +endef + +define print_info + printf "\033[1;36m%s\033[0m\n" "$(1)" +endef + + +.PHONY: check/deps +check/deps: ##H Verify kcov & shellcheck + @command -v shellcheck >/dev/null 2>&1 || { $(call print_err,Error: 'shellcheck' not installed.); exit 1; } + @$(call print_info, --- shellcheck version ---) && shellcheck --version + @command -v kcov >/dev/null 2>&1 || { $(call print_err,Error: 'kcov' not installed.); exit 1; } + @$(call print_info, --- kcov version ---) && kcov --version + @$(call print_success,Dependencies OK.) + + +.PHONY: lint +lint: ##H Run shellcheck + # lint install script + shellcheck install.sh + @$(call print_success,OK.) + # lint system/binary script + shellcheck git-remote-gcrypt + @$(call print_success,OK.) + # lint test scripts + shellcheck tests/*.sh + @$(call print_success,OK.) + +# --- Test Config --- +PWD := $(shell pwd) +COV_ROOT := $(PWD)/.coverage +COV_SYSTEM := $(COV_ROOT)/system +COV_INSTALL := $(COV_ROOT)/installer + +.PHONY: test/, test +test/: test +test: test/installer test/system test/cov ##H All tests & coverage + +.PHONY: test/installer +test/installer: check/deps ##H Test installer logic + @rm -rf $(COV_INSTALL) + @mkdir -p $(COV_INSTALL) + @export COV_DIR=$(COV_INSTALL); \ + kcov --bash-handle-sh-invocation \ + --include-pattern=install.sh \ + --exclude-path=$(PWD)/.git,$(PWD)/tests \ + $(COV_INSTALL) \ + ./tests/test-install-logic.sh + + +.PHONY: test/system +test/system: check/deps ##H Test system functionality + @rm -rf $(COV_SYSTEM) + @mkdir -p $(COV_SYSTEM) + @export GPG_TTY=$$(tty); \ + [ -n "$(DEBUG)$(V)" ] && export GCRYPT_DEBUG=1 && print_warn "Debug mode enabled"; \ + export GIT_CONFIG_PARAMETERS="'gcrypt.gpg-args=--pinentry-mode loopback --no-tty'"; \ + export COV_DIR=$(COV_SYSTEM); \ + for test_script in tests/system-test*.sh; do \ + kcov --include-path=$(PWD) \ + --include-pattern=git-remote-gcrypt \ + --exclude-path=$(PWD)/.git,$(PWD)/tests \ + $(COV_SYSTEM) \ + ./$$test_script || true; \ + done + + +define CHECK_COVERAGE +@XML_FILE=$$(find $(1) -name "cobertura.xml" 2>/dev/null | grep "merged" | head -n 1); \ +[ -z "$$XML_FILE" ] && XML_FILE=$$(find $(1) -name "cobertura.xml" 2>/dev/null | head -n 1); \ +if [ -f "$$XML_FILE" ]; then \ + echo ""; \ + echo "Report for: file://$$(dirname "$$XML_FILE")/index.html"; \ + XML_FILE="$$XML_FILE" PATT="$(2)" python3 tests/coverage_report.py; \ + fi +endef + +.PHONY: test/cov +test/cov: ##H Show coverage gaps + $(call CHECK_COVERAGE,$(COV_SYSTEM),git-remote-gcrypt) + $(call CHECK_COVERAGE,$(COV_INSTALL),install.sh) + + + +.PHONY: install/, install +install/: install +install: ##H Install system-wide + @$(call print_target,install) + @$(call print_info,Installing git-remote-gcrypt...) + @bash ./install.sh + @$(call print_success,Installed.) + +.PHONY: install/user +install/user: ##H make install prefix=~/.local + $(MAKE) install prefix=~/.local + +.PHONY: check/install +check/install: ##H Verify installation works + bash ./tests/verify-system-install.sh + +.PHONY: uninstall/, uninstall +uninstall/: uninstall +uninstall: ##H Uninstall + @$(call print_target,uninstall) + @bash ./uninstall.sh + @$(call print_success,Uninstalled.) + +.PHONY: uninstall/user +uninstall/user: ##H make uninstall prefix=~/.local + $(MAKE) uninstall prefix=~/.local + + + +.PHONY: clean +clean: ##H Clean up + rm -rf .coverage diff --git a/git-remote-gcrypt b/git-remote-gcrypt index ccb374e..550e8c2 100755 --- a/git-remote-gcrypt +++ b/git-remote-gcrypt @@ -25,6 +25,12 @@ set -e # errexit set -f # noglob set -C # noclobber +VERSION="@@DEV_VERSION@@" +if [ "${1:-}" = "-v" ] || [ "${1:-}" = "--version" ]; then + echo "git-remote-gcrypt version $VERSION" >&2 + exit 0 +fi + export GITCEPTION="${GITCEPTION:-}+" # Reuse $Gref except when stacked Gref="refs/gcrypt/gitception$GITCEPTION" Gref_rbranch="refs/heads/master" @@ -366,7 +372,7 @@ PRIVDECRYPT() { local status_= exec 4>&1 && - status_=$(rungpg --status-fd 3 -q -d 3>&1 1>&4 || { + status_=$(rungpg --status-fd 3 -q -d 3>&1 1>&4 || { rc=$? print_debug "rungpg failed with exit code $rc" echo_info "ignoring GPG errors (likely anonymous recipient OR pinentry)." @@ -865,8 +871,8 @@ EOF done <&2 && "$@"; } -install_v() -{ + +install_v() { # Install $1 into $2/ with mode $3 verbose install -d "$2" && - verbose install -m "$3" "$1" "$2" + verbose install -m "$3" "$1" "$2" } -install_v git-remote-gcrypt "$DESTDIR$prefix/bin" 755 +# --- VERSION DETECTION --- +if [ -f /etc/os-release ]; then + . /etc/os-release + OS_IDENTIFIER=$ID # Linux +elif command -v uname >/dev/null; then + # Fallback for macOS/BSD (darwin) + OS_IDENTIFIER=$(uname -s | tr '[:upper:]' '[:lower:]') +else + OS_IDENTIFIER="unknown_OS" +fi + +# Get base version then append OS identifier +if [ -d .git ] && command -v git >/dev/null; then + VERSION=$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD 2>/dev/null || echo "sha_unknown") +else + if [ ! -f debian/changelog ]; then + echo "Error: debian/changelog not found (and not a git repo)" >&2 + exit 1 + fi + VERSION=$(grep ^git-remote-gcrypt debian/changelog | head -n 1 | awk '{print $2}' | tr -d '()') +fi +VERSION="$VERSION (deb running on $OS_IDENTIFIER)" + +echo "Detected version: $VERSION" -if command -v rst2man >/dev/null -then +# Setup temporary build area +BUILD_DIR="./.build_tmp" +mkdir -p "$BUILD_DIR" +trap 'rm -rf "$BUILD_DIR"' EXIT + +# Placeholder injection +sed "s/@@DEV_VERSION@@/$VERSION/g" git-remote-gcrypt >"$BUILD_DIR/git-remote-gcrypt" + +# --- INSTALLATION --- +# This is where the 'Permission denied' happens if not sudo +install_v "$BUILD_DIR/git-remote-gcrypt" "$DESTDIR$prefix/bin" 755 + +if command -v rst2man >/dev/null; then rst2man='rst2man' -elif command -v rst2man.py >/dev/null # it is installed as rst2man.py on macOS -then +elif command -v rst2man.py >/dev/null; then # it is installed as rst2man.py on macOS rst2man='rst2man.py' fi -if [ -n "$rst2man" ] -then - trap 'rm -f git-remote-gcrypt.1.gz' EXIT - verbose $rst2man ./README.rst | gzip -9 > git-remote-gcrypt.1.gz +if [ -n "$rst2man" ]; then + # Update trap to clean up manpage too + trap 'rm -rf "$BUILD_DIR"; rm -f git-remote-gcrypt.1.gz' EXIT + verbose "$rst2man" ./README.rst | gzip -9 >git-remote-gcrypt.1.gz install_v git-remote-gcrypt.1.gz "$DESTDIR$prefix/share/man/man1" 644 else echo "'rst2man' not found, man page not installed" >&2 fi + +# Suggest installing shell completions +cat >&2 < 0: + COVERED = total_lines - missed_lines + pct = (COVERED / total_lines) * 100 + COLOR = "\033[32;1m" if pct > 80 else "\033[33;1m" if pct > 50 else "\033[31;1m" + print(f"{COLOR}Coverage: {pct:.1f}% ({COVERED}/{total_lines})\033[0m") +else: + print(f"Coverage: N/A (0 lines found for {patt})") + +if missed: + print(f"\033[31;1m{len(missed)} missing lines\033[0m in {patt}:") + print( + textwrap.fill( + ", ".join(missed), width=72, initial_indent=" ", subsequent_indent=" " + ) + ) diff --git a/tests/system-test.sh b/tests/system-test.sh index 74de3a5..ecb0340 100755 --- a/tests/system-test.sh +++ b/tests/system-test.sh @@ -4,6 +4,14 @@ set -efuC -o pipefail shopt -s inherit_errexit +# Helpers +print_info() { printf "\033[1;36m%s\033[0m\n" "$1"; } +print_success() { printf "\033[1;34m✓ %s\033[0m\n" "$1"; } +print_err() { printf "\033[1;31m%s\033[0m\n" "$1"; } + + + + # Unlike the main git-remote-gcrypt program, this testing script requires bash # (rather than POSIX sh) and also depends on various common system utilities # that the git-remote-gcrypt carefully avoids using (such as mktemp(1)). @@ -29,6 +37,8 @@ pack_size_limit="12m" # If this variable is unset, there is no size limit. readonly num_commits files_per_commit random_source random_data_per_file \ default_branch test_user_name test_user_email pack_size_limit +print_info "Running system test..." + # Pipe text into this function to indent it with four spaces. This is used # to make the output of this script prettier. indent() { @@ -44,8 +54,8 @@ section_break() { assert() { (set +e; [[ -n ${show_command:-} ]] && set -x; "${@}") local -r status=${?} - { [[ ${status} -eq 0 ]] && echo "Verification succeeded."; } || \ - echo "Verification failed." + { [[ ${status} -eq 0 ]] && print_success "Verification succeeded."; } || \ + print_err "Verification failed." return "${status}" } @@ -111,7 +121,8 @@ random_data_file="${tempdir}/data" head -c "${random_data_size}" "${random_source}" > "${random_data_file}" # Create gpg key and subkey. -echo "Step 1: Creating a new GPG key and subkey to use for testing:" +# Create gpg key and subkey. +print_info "Step 1: Creating a new GPG key and subkey to use for testing:" ( set -x gpg --batch --passphrase "" --quick-generate-key \ @@ -122,7 +133,7 @@ echo "Step 1: Creating a new GPG key and subkey to use for testing:" ### section_break -echo "Step 2: Creating new repository with random data:" +print_info "Step 2: Creating new repository with random data:" { git init -- "${tempdir}/first" cd "${tempdir}/first" @@ -154,14 +165,14 @@ echo "Step 2: Creating new repository with random data:" ### section_break -echo "Step 3: Creating an empty bare repository to receive pushed data:" +print_info "Step 3: Creating an empty bare repository to receive pushed data:" git init --bare -- "${tempdir}/second.git" | indent ### section_break -echo "Step 4: Pushing the first repository to the second one using gitception:" +print_info "Step 4: Pushing the first repository to the second one using gitception:" { # Note that when pushing to a bare local repository, git-remote-gcrypt uses # gitception, rather than treating the remote as a local repository. @@ -197,7 +208,7 @@ echo "Step 4: Pushing the first repository to the second one using gitception:" ### section_break -echo "Step 5: Cloning the second repository using gitception:" +print_info "Step 5: Cloning the second repository using gitception:" { ( set -x @@ -221,3 +232,5 @@ echo "Step 5: Cloning the second repository using gitception:" show_command=1 assert diff -r --exclude ".git" -- \ "${tempdir}/first" "${tempdir}/third" 2>&1 | indent } | indent + +[ -n "${COV_DIR:-}" ] && print_success "OK. Report: file://${COV_DIR}/index.html" diff --git a/tests/test-install-logic.sh b/tests/test-install-logic.sh new file mode 100755 index 0000000..2fdc79c --- /dev/null +++ b/tests/test-install-logic.sh @@ -0,0 +1,106 @@ +#!/bin/bash +set -u + +# 1. Setup Sandbox +SANDBOX=$(mktemp -d) +trap 'rm -rf "$SANDBOX"' EXIT + +# Helpers +print_info() { printf "\033[1;36m[TEST] %s\033[0m\n" "$1"; } +print_success() { printf "\033[1;34m[TEST] ✓ %s\033[0m\n" "$1"; } +print_err() { printf "\033[1;31m[TEST] FAIL: %s\033[0m\n" "$1"; } + +print_info "Running install logic tests in $SANDBOX..." + +# 2. Copy artifacts +cp git-remote-gcrypt "$SANDBOX" +cp README.rst "$SANDBOX" 2>/dev/null || touch "$SANDBOX/README.rst" +cp install.sh "$SANDBOX" +cd "$SANDBOX" || exit 2 + +# Ensure source binary has the placeholder for sed to work on +# If your local git-remote-gcrypt already has a real version, sed won't find the tag +if ! grep -q "@@DEV_VERSION@@" git-remote-gcrypt; then + echo 'VERSION="@@DEV_VERSION@@"' >git-remote-gcrypt +fi +chmod +x git-remote-gcrypt + +INSTALLER="./install.sh" + +assert_version() { + EXPECTED_SUBSTRING="$1" + PREFIX="$SANDBOX/usr" + export prefix="$PREFIX" + unset DESTDIR + + # Run the installer + "$INSTALLER" >/dev/null 2>&1 || { + echo "Installer failed unexpectedly" + return 1 + } + + INSTALLED_BIN="$PREFIX/bin/git-remote-gcrypt" + chmod +x "$INSTALLED_BIN" + + OUTPUT=$("$INSTALLED_BIN" --version 2>&1 /dev/null 2>&1; then + print_err "FAILED: Installer should have exited 1 without debian/changelog" + exit 1 +else + printf " ✓ %s\n" "Installer strictly requires metadata" +fi + +# --- TEST 2: Debian-sourced Versioning --- +echo "--- Test 2: Versioning from Changelog ---" +mkdir -p debian +echo "git-remote-gcrypt (5.5.5-1) unstable; urgency=low" >debian/changelog + +# Determine the OS identifier for the test expectation +if [ -f /etc/os-release ]; then + # shellcheck source=/dev/null + source /etc/os-release + OS_IDENTIFIER="$ID" +elif command -v uname >/dev/null; then + OS_IDENTIFIER=$(uname -s | tr '[:upper:]' '[:lower:]') +else + OS_IDENTIFIER="unknown_os" +fi + +# Use the identified OS for the expected string +EXPECTED_TAG="5.5.5-1 (deb running on $OS_IDENTIFIER)" + +assert_version "$EXPECTED_TAG" + +# --- TEST 3: DESTDIR Support --- +echo "--- Test 3: DESTDIR Support ---" +rm -rf "${SANDBOX:?}/usr" +export DESTDIR="$SANDBOX/pkg_root" +export prefix="/usr" + +"$INSTALLER" >/dev/null 2>&1 + +if [ -f "$SANDBOX/pkg_root/usr/bin/git-remote-gcrypt" ]; then + printf " ✓ %s\n" "DESTDIR honored" +else + print_err "FAILED: Binary not found in DESTDIR" + exit 1 +fi + +print_success "All install logic tests passed." +[ -n "${COV_DIR:-}" ] && print_success "OK. Report: file://${COV_DIR}/index.html" + +exit 0 diff --git a/tests/verify-system-install.sh b/tests/verify-system-install.sh new file mode 100644 index 0000000..c84575b --- /dev/null +++ b/tests/verify-system-install.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -u + +# Helpers +print_info() { printf "\033[1;36m%s\033[0m\n" "$1"; } +print_success() { printf "\033[1;34m✓ %s\033[0m\n" "$1"; } +print_err() { printf "\033[1;31m%s\033[0m\n" "$1"; } + +print_info "Verifying system install..." + +# 1. Check if the command exists in the path +if ! command -v git-remote-gcrypt >/dev/null; then + print_err "ERROR: git-remote-gcrypt is not in the PATH." + exit 1 +fi + +# 2. Run the version check (Capture stderr too!) +OUTPUT=$(git-remote-gcrypt -v 2>&1) +EXIT_CODE=$? + +if [ $EXIT_CODE -ne 0 ]; then + print_err "ERROR: Command exited with code $EXIT_CODE" + exit 1 +fi + +# 3. Verify the placeholder was replaced +if [[ "$OUTPUT" == *"@@DEV_VERSION@@"* ]]; then + print_err "ERROR: Version placeholder @@DEV_VERSION@@ was not replaced!" + exit 1 +fi + +# 4. Determine expected ID for comparison to actual +if [ -f /etc/os-release ]; then + # shellcheck source=/dev/null + source /etc/os-release + EXPECTED_ID=$ID +elif command -v uname >/dev/null; then + EXPECTED_ID=$(uname -s | tr '[:upper:]' '[:lower:]') +else + EXPECTED_ID="unknown_OS" +fi + +if [[ "$OUTPUT" != *"(deb running on $EXPECTED_ID)"* ]]; then + print_err "ERROR: Distro ID '$EXPECTED_ID' missing from version string! (Got: $OUTPUT)" + exit 1 +fi + +# LEAD with the version success +printf " ✓ %s\n" "VERSION OK: $OUTPUT" + +# 5. Verify the man page +if man -w git-remote-gcrypt >/dev/null 2>&1; then + printf " ✓ %s\n" "DOCS OK: Man page is installed and indexed." +else + print_err "ERROR: Man page not found in system paths." + exit 1 +fi + +print_success "INSTALLATION VERIFIED" -- 2.52.0