diff --git a/.github/workflows/commit-identity.yml b/.github/workflows/commit-identity.yml new file mode 100644 index 0000000..04a3eb0 --- /dev/null +++ b/.github/workflows/commit-identity.yml @@ -0,0 +1,24 @@ +name: Commit identity + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + reject-fixture-identity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Reject fixture commit identities + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: bash scripts/check-commit-identity.sh "$BASE_SHA" "$HEAD_SHA" diff --git a/scripts/check-commit-identity.sh b/scripts/check-commit-identity.sh new file mode 100755 index 0000000..8625f32 --- /dev/null +++ b/scripts/check-commit-identity.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Reject fixture identities from a verified commit range. +set -uo pipefail + +BASE="${1:?usage: check-commit-identity.sh }" +HEAD="${2:?usage: check-commit-identity.sh }" + +if ! git rev-parse --verify --quiet "$BASE^{commit}" >/dev/null || \ + ! git rev-parse --verify --quiet "$HEAD^{commit}" >/dev/null; then + echo "FAIL: commit identity policy requires two commits" >&2 + exit 2 +fi + +RANGE_BASE="$(git merge-base "$BASE" "$HEAD")" || { + echo "FAIL: commit identity policy cannot derive a merge base" >&2 + exit 2 +} + +is_fixture_name() { + case "$1" in + [Ff][Ii][Xx][Tt][Uu][Rr][Ee]) return 0 ;; + *) return 1 ;; + esac +} + +is_fixture_email() { + case "$1" in + [Ff][Ii][Xx][Tt][Uu][Rr][Ee]@[Ee][Xx][Aa][Mm][Pp][Ll][Ee].[Ii][Nn][Vv][Aa][Ll][Ii][Dd]) return 0 ;; + *) return 1 ;; + esac +} + +FAILED=0 +while IFS= read -r -d '' sha && + IFS= read -r -d '' author_name && + IFS= read -r -d '' author_email && + IFS= read -r -d '' committer_name && + IFS= read -r -d '' committer_email; do + if is_fixture_name "$author_name" || is_fixture_email "$author_email"; then + echo "FAIL: fixture identity in author for commit $sha" >&2 + FAILED=1 + fi + if is_fixture_name "$committer_name" || is_fixture_email "$committer_email"; then + echo "FAIL: fixture identity in committer for commit $sha" >&2 + FAILED=1 + fi +done < <(git log --format='%H%x00%an%x00%ae%x00%cn%x00%ce%x00' "$RANGE_BASE..$HEAD") + +if [[ $FAILED -ne 0 ]]; then + exit 1 +fi + +echo "ok: commit identities accepted" diff --git a/scripts/git-identity-invariant.sh b/scripts/git-identity-invariant.sh new file mode 100644 index 0000000..3690b70 --- /dev/null +++ b/scripts/git-identity-invariant.sh @@ -0,0 +1,33 @@ +# Shared Git identity boundary for validation scripts. +# A non-Git copied fixture returns an empty baseline and is intentionally skipped. + +git_identity_baseline() { + local repo="$1" repo_root worktree_root config status + + repo_root="$(cd "$repo" && pwd -P)" || return 0 + [[ "$(git -C "$repo_root" rev-parse --is-inside-work-tree 2>/dev/null)" == "true" ]] || return 0 + worktree_root="$(git -C "$repo_root" rev-parse --show-toplevel 2>/dev/null)" || return 0 + worktree_root="$(cd "$worktree_root" && pwd -P)" || return 0 + [[ "$worktree_root" == "$repo_root" ]] || return 0 + + config="$(git -C "$repo_root" config --local --get-regexp '^user\.(name|email)$' 2>/dev/null)"; status=$? + [[ $status -eq 0 ]] && printf '%s\n' "$config" + [[ $status -eq 0 || $status -eq 1 ]] && return 0 + return "$status" +} + +git_identity_unchanged() { + local repo="$1" baseline="$2" observed + + if ! observed="$(git_identity_baseline "$repo")"; then + printf '%s\n' '[git-identity] could not read local user.name/user.email' >&2 + return 1 + fi + + if [[ "$observed" == "$baseline" ]]; then + return 0 + fi + + printf '%s\n' '[git-identity] local user.name/user.email changed during validation' >&2 + return 1 +} diff --git a/scripts/test-commit-identity-policy.sh b/scripts/test-commit-identity-policy.sh new file mode 100755 index 0000000..c05980e --- /dev/null +++ b/scripts/test-commit-identity-policy.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Fixture tests for the protected-branch commit identity policy. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +POLICY="$ROOT/scripts/check-commit-identity.sh" +FAIL_COUNT=0 + +setup_repo() { + local dir + dir="$(mktemp -d)" || return 1 + git -C "$dir" init -q || { rm -rf "$dir"; return 1; } + git -C "$dir" config user.name "Canonical Fixture" || { rm -rf "$dir"; return 1; } + git -C "$dir" config user.email "canonical@example.invalid" || { rm -rf "$dir"; return 1; } + git -C "$dir" config commit.gpgsign false || { rm -rf "$dir"; return 1; } + printf 'base\n' >"$dir/file" + git -C "$dir" add file && git -C "$dir" commit -qm base || { rm -rf "$dir"; return 1; } + printf '%s\n' "$dir" +} + +assert_contains() { + local haystack="$1" needle="$2" label="$3" + if [[ "$haystack" == *"$needle"* ]]; then + return 0 + fi + echo " assertion failed ($label): expected output to contain: $needle" + return 1 +} + +run_case() { + local name="$1" + shift + echo "Case $name:" + if "$@"; then + echo " pass" + else + echo " FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +case_normal_identity_accepted() { + local dir base out code + dir="$(setup_repo)" || return 1 + base="$(git -C "$dir" rev-parse HEAD)" + printf 'normal\n' >>"$dir/file" + git -C "$dir" add file && git -C "$dir" commit -qm normal || { rm -rf "$dir"; return 1; } + out="$(cd "$dir" && "$POLICY" "$base" HEAD 2>&1)"; code=$? + rm -rf "$dir" + [[ $code -eq 0 ]] && assert_contains "$out" "commit identities accepted" "normal identity" +} + +case_fixture_author_rejected() { + local dir base out code + dir="$(setup_repo)" || return 1 + base="$(git -C "$dir" rev-parse HEAD)" + git -C "$dir" config user.name fixture + git -C "$dir" config user.email fixture@example.invalid + printf 'fixture author\n' >>"$dir/file" + git -C "$dir" add file && git -C "$dir" commit -qm fixture || { rm -rf "$dir"; return 1; } + out="$(cd "$dir" && "$POLICY" "$base" HEAD 2>&1)"; code=$? + rm -rf "$dir" + [[ $code -ne 0 ]] && assert_contains "$out" "fixture identity" "fixture author diagnostic" +} + +case_fixture_committer_rejected() { + local dir base out code + dir="$(setup_repo)" || return 1 + base="$(git -C "$dir" rev-parse HEAD)" + printf 'fixture committer\n' >>"$dir/file" + git -C "$dir" add file || { rm -rf "$dir"; return 1; } + GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=fixture@example.invalid git -C "$dir" commit -qm committer || { rm -rf "$dir"; return 1; } + out="$(cd "$dir" && "$POLICY" "$base" HEAD 2>&1)"; code=$? + rm -rf "$dir" + [[ $code -ne 0 ]] && assert_contains "$out" "fixture identity" "fixture committer diagnostic" +} + +case_diverged_branch_accepted() { + local dir base branch out code + dir="$(setup_repo)" || return 1 + branch="$(git -C "$dir" symbolic-ref --short HEAD)" + git -C "$dir" checkout -qb feature || { rm -rf "$dir"; return 1; } + printf 'feature\n' >>"$dir/file" + git -C "$dir" add file && git -C "$dir" commit -qm feature || { rm -rf "$dir"; return 1; } + git -C "$dir" checkout -q "$branch" || { rm -rf "$dir"; return 1; } + printf 'main\n' >>"$dir/file" + git -C "$dir" add file && git -C "$dir" commit -qm main || { rm -rf "$dir"; return 1; } + base="$(git -C "$dir" rev-parse HEAD)" + out="$(cd "$dir" && "$POLICY" "$base" feature 2>&1)"; code=$? + rm -rf "$dir" + [[ $code -eq 0 ]] && assert_contains "$out" "commit identities accepted" "diverged branch" +} + +run_case normal-identity-accepted case_normal_identity_accepted +run_case fixture-author-rejected case_fixture_author_rejected +run_case fixture-committer-rejected case_fixture_committer_rejected +run_case diverged-branch-accepted case_diverged_branch_accepted + +if [[ $FAIL_COUNT -eq 0 ]]; then + echo "ALL COMMIT IDENTITY POLICY CHECKS PASSED" + exit 0 +fi + +echo "$FAIL_COUNT commit identity policy check(s) failed" +exit 1 diff --git a/scripts/test-git-identity-isolation.sh b/scripts/test-git-identity-isolation.sh new file mode 100755 index 0000000..272829c --- /dev/null +++ b/scripts/test-git-identity-isolation.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Unit tests for the Git identity boundary used by validation scripts. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FAIL_COUNT=0 + +setup_repo() { + local dir + dir="$(mktemp -d 2>&1)" || { echo " harness error: mktemp -d failed: $dir" >&2; return 1; } + git -C "$dir" init -q || { rm -rf "$dir"; return 1; } + git -C "$dir" config user.name "Canonical Fixture" || { rm -rf "$dir"; return 1; } + git -C "$dir" config user.email "canonical@example.invalid" || { rm -rf "$dir"; return 1; } + printf '%s\n' "$dir" +} + +assert_contains() { + local haystack="$1" needle="$2" label="$3" + if [[ "$haystack" == *"$needle"* ]]; then + return 0 + fi + echo " assertion failed ($label): expected output to contain: $needle" + return 1 +} + +assert_equal() { + local actual="$1" expected="$2" label="$3" + if [[ "$actual" == "$expected" ]]; then + return 0 + fi + echo " assertion failed ($label): expected '$expected', got '$actual'" + return 1 +} + +run_case() { + local name="$1" + shift + echo "Case $name:" + if "$@"; then + echo " pass" + else + echo " FAIL" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +case_identity_preserved() { + local dir before after + dir="$(setup_repo)" || return 1 + before="$(git_identity_baseline "$dir")" + after="$(git_identity_baseline "$dir")" + rm -rf "$dir" + [[ "$after" == "$before" ]] +} + +case_identity_mutation_detected() { + local dir before out code + dir="$(setup_repo)" || return 1 + before="$(git_identity_baseline "$dir")" + git -C "$dir" config user.name fixture + out="$(git_identity_unchanged "$dir" "$before" 2>&1)"; code=$? + rm -rf "$dir" + [[ $code -ne 0 ]] && assert_contains "$out" "[git-identity] local user.name/user.email changed during validation" "mutation diagnostic" +} + +case_email_mutation_detected() { + local dir before out code + dir="$(setup_repo)" || return 1 + before="$(git_identity_baseline "$dir")" + git -C "$dir" config user.email fixture@example.invalid + out="$(git_identity_unchanged "$dir" "$before" 2>&1)"; code=$? + rm -rf "$dir" + [[ $code -ne 0 ]] && assert_contains "$out" "[git-identity] local user.name/user.email changed during validation" "email mutation diagnostic" +} + +case_bare_repository_skipped() { + local dir baseline + dir="$(mktemp -d)" || return 1 + git -C "$dir" init --bare -q || { rm -rf "$dir"; return 1; } + baseline="$(git_identity_baseline "$dir")" + rm -rf "$dir" + assert_equal "$baseline" "" "bare repository skipped" +} + +case_metadata_less_child_skipped() { + local dir baseline + dir="$(setup_repo)" || return 1 + mkdir "$dir/fixture" + baseline="$(git_identity_baseline "$dir/fixture")" + rm -rf "$dir" + assert_equal "$baseline" "" "metadata-less child skipped" +} + +# shellcheck source=scripts/git-identity-invariant.sh +source "$ROOT/scripts/git-identity-invariant.sh" + +run_case identity-preserved case_identity_preserved +run_case identity-mutation-detected case_identity_mutation_detected +run_case bare-repository-skipped case_bare_repository_skipped +run_case metadata-less-child-skipped case_metadata_less_child_skipped +run_case email-mutation-detected case_email_mutation_detected + +if [[ $FAIL_COUNT -eq 0 ]]; then + echo "ALL GIT IDENTITY ISOLATION CHECKS PASSED" + exit 0 +fi + +echo "$FAIL_COUNT Git identity isolation check(s) failed" +exit 1 diff --git a/scripts/test-plan-consumer-portability.sh b/scripts/test-plan-consumer-portability.sh index 0f2b1d5..8493992 100755 --- a/scripts/test-plan-consumer-portability.sh +++ b/scripts/test-plan-consumer-portability.sh @@ -6,6 +6,8 @@ set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/git-identity-invariant.sh" +IDENTITY_BEFORE="$(git_identity_baseline "$ROOT")" PASS_COUNT=0 FAIL_COUNT=0 @@ -2764,6 +2766,9 @@ else fail "phase consumers derive one portable artifact root from the exact progress path" fi rm -rf "$fixture" "$consumer_repo" +if ! git_identity_unchanged "$ROOT" "$IDENTITY_BEFORE"; then + fail "local user.name/user.email unchanged" +fi echo "Summary: $PASS_COUNT passed, $FAIL_COUNT failed" if [ "$FAIL_COUNT" -ne 0 ]; then diff --git a/scripts/validate.sh b/scripts/validate.sh index 52680d4..d453143 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -4,6 +4,8 @@ set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/git-identity-invariant.sh" +IDENTITY_BEFORE="$(git_identity_baseline "$ROOT")" FAIL=0 fail() { echo "FAIL: $1"; FAIL=1; } @@ -1266,6 +1268,15 @@ fi if ! bash "$ROOT/scripts/test-release-loop-conformance.sh" static; then fail "release-loop static conformance failed" fi +if ! bash "$ROOT/scripts/test-git-identity-isolation.sh"; then + FAIL=1 +fi +if ! bash "$ROOT/scripts/test-commit-identity-policy.sh"; then + FAIL=1 +fi +if ! git_identity_unchanged "$ROOT" "$IDENTITY_BEFORE"; then + FAIL=1 +fi echo if [ "$FAIL" -eq 0 ]; then