From 6469086b20ad1b157da4c3b5f973ee032c836c78 Mon Sep 17 00:00:00 2001 From: Cameron Brooks Date: Mon, 28 Sep 2026 12:40:58 -0400 Subject: [PATCH] feat(workflows): add a reusable commit-email check for public repos --- .github/workflows/commit-email.yml | 77 ++++++++++++++++++++++++++++++ tests/commit-email.test.sh | 54 +++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 .github/workflows/commit-email.yml create mode 100644 tests/commit-email.test.sh diff --git a/.github/workflows/commit-email.yml b/.github/workflows/commit-email.yml new file mode 100644 index 0000000..688956d --- /dev/null +++ b/.github/workflows/commit-email.yml @@ -0,0 +1,77 @@ +name: commit-email + +# Reusable check for PUBLIC repos: every commit a pull request (or push) adds +# must be authored and committed with a work address. Git identity is published +# in every commit and cannot be corrected without rewriting history other people +# have cloned, so on a public repo it is checked before merge. +# +# It checks only the commits being added, never existing history, so adopting +# it does not require a rewrite. Bot identities GitHub itself assigns (Renovate +# and other `[bot]` apps, Copilot, and `noreply@github.com` as the committer of a +# web-UI merge) are allowed. +# +# Consume from a public repo with a caller workflow: +# on: [pull_request, push] +# jobs: +# commit-email: +# uses: FlowMatrix-AI/.github/.github/workflows/commit-email.yml@main +# +# Standard: FlowMatrix-AI eng-governance, commit identity on public repos. + +on: + workflow_call: + inputs: + domain: + description: Required email domain for human authors and committers + type: string + default: "flowmatrixai.com" + +permissions: + contents: read + +jobs: + check: + name: commit-email + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + + - name: Check commit author and committer emails + env: + DOMAIN: ${{ inputs.domain }} + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + # A new branch's first push has an all-zero "before"; check HEAD alone. + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then + range="$HEAD -1" + else + range="$BASE..$HEAD" + fi + allowed() { + case "$1" in + *@"$DOMAIN") return 0 ;; + noreply@github.com) return 0 ;; + *"[bot]@users.noreply.github.com") return 0 ;; + *+Copilot@users.noreply.github.com) return 0 ;; + esac + return 1 + } + bad=0 + # shellcheck disable=SC2086 # range is "A..B" or "SHA -1" by design + while IFS='|' read -r sha ae ce; do + for e in "$ae" "$ce"; do + if ! allowed "$e"; then + echo "::error::${sha:0:7} uses $e (want *@$DOMAIN)" + bad=1 + fi + done + done < <(git log --format='%H|%ae|%ce' $range) + if [ "$bad" -ne 0 ]; then + echo "Fix: git config user.email @$DOMAIN in this repo, then amend or rebase the listed commits before merging." + exit 1 + fi + echo "All commits in $range use a *@$DOMAIN or bot identity." diff --git a/tests/commit-email.test.sh b/tests/commit-email.test.sh new file mode 100644 index 0000000..ab5cfb2 --- /dev/null +++ b/tests/commit-email.test.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Runs the exact `run:` block from commit-email.yml against throwaway git repos +# in a temp dir. Usage: bash tests/commit-email.test.sh +set -uo pipefail +WF="${1:-$(dirname "$0")/../.github/workflows/commit-email.yml}" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +python3 -c "import yaml,sys;d=yaml.safe_load(open(sys.argv[1]));print(d['jobs']['check']['steps'][1]['run'])" "$WF" > "$T/check.sh" + +mk() { # dir + git init -q "$1"; git -C "$1" -c user.email=cb@flowmatrixai.com -c user.name=base commit -q --allow-empty -m base +} +add() { # dir author-email committer-email + GIT_AUTHOR_NAME=a GIT_AUTHOR_EMAIL="$2" GIT_COMMITTER_NAME=c GIT_COMMITTER_EMAIL="$3" \ + git -C "$1" commit -q --allow-empty -m "c $2" +} +run() { # name dir base expect + local base="$3" head out rc + head=$(git -C "$2" rev-parse HEAD) + out=$(cd "$2" && DOMAIN=flowmatrixai.com BASE="$base" HEAD="$head" bash "$T/check.sh" 2>&1); rc=$? + if { [ "$4" = pass ] && [ $rc -eq 0 ]; } || { [ "$4" = fail ] && [ $rc -ne 0 ]; }; then + echo "ok - $1 (exit $rc)" + else + echo "FAIL - $1 (exit $rc, wanted $4)"; echo "$out"; FAILED=1 + fi +} +FAILED=0 + +mk "$T/a"; b=$(git -C "$T/a" rev-parse HEAD); add "$T/a" cb@flowmatrixai.com cb@flowmatrixai.com +run "work-address commit passes" "$T/a" "$b" pass + +mk "$T/b"; b=$(git -C "$T/b" rev-parse HEAD); add "$T/b" someone@gmail.com someone@gmail.com +run "personal-address commit fails" "$T/b" "$b" fail + +mk "$T/c"; b=$(git -C "$T/c" rev-parse HEAD); add "$T/c" cb@flowmatrixai.com someone@gmail.com +run "work author, personal committer fails" "$T/c" "$b" fail + +mk "$T/d"; b=$(git -C "$T/d" rev-parse HEAD) +add "$T/d" "29139614+renovate[bot]@users.noreply.github.com" noreply@github.com +add "$T/d" "198982749+Copilot@users.noreply.github.com" "198982749+Copilot@users.noreply.github.com" +run "bot identities pass" "$T/d" "$b" pass + +mk "$T/e"; git -C "$T/e" -c user.email=old@gmail.com -c user.name=o commit -q --allow-empty -m old +b=$(git -C "$T/e" rev-parse HEAD); add "$T/e" cb@flowmatrixai.com cb@flowmatrixai.com +run "existing personal history is not re-checked" "$T/e" "$b" pass + +mk "$T/f"; add "$T/f" someone@gmail.com someone@gmail.com +run "new-branch push (zero base) checks HEAD" "$T/f" 0000000000000000000000000000000000000000 fail + +mk "$T/g"; b=$(git -C "$T/g" rev-parse HEAD) +add "$T/g" someone@gmail.com someone@gmail.com; add "$T/g" cb@flowmatrixai.com cb@flowmatrixai.com +run "personal commit below a work HEAD in the range fails" "$T/g" "$b" fail + +exit $FAILED